@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/tailscale/run.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ensureExecutable, rethrowIfMissing } from "@openparachute/depcheck";
|
|
2
|
+
|
|
1
3
|
export interface CommandResult {
|
|
2
4
|
code: number;
|
|
3
5
|
stdout: string;
|
|
@@ -7,19 +9,34 @@ export interface CommandResult {
|
|
|
7
9
|
export type Runner = (cmd: readonly string[]) => Promise<CommandResult>;
|
|
8
10
|
|
|
9
11
|
export async function defaultRunner(cmd: readonly string[]): Promise<CommandResult> {
|
|
12
|
+
// Pre-flight the binary so a missing `tailscale` surfaces the friendly
|
|
13
|
+
// install UX (`@openparachute/depcheck`) instead of a raw spawn throw —
|
|
14
|
+
// closes the no-hint gap where `parachute expose tailnet` on a box without
|
|
15
|
+
// tailscale died with `Executable not found in $PATH: "tailscale"`.
|
|
16
|
+
// `cmd[0]` is always present for a real call; guard for the empty edge.
|
|
17
|
+
const binary = cmd[0];
|
|
18
|
+
if (binary) ensureExecutable(binary);
|
|
10
19
|
// Inherit env so `tailscale` sees PATH (and HOME for state dir). Bun.spawn
|
|
11
20
|
// defaults to empty env — see api-modules-ops.ts:defaultRun for rationale.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
try {
|
|
22
|
+
const proc = Bun.spawn([...cmd], {
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
env: process.env,
|
|
26
|
+
});
|
|
27
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
28
|
+
new Response(proc.stdout).text(),
|
|
29
|
+
new Response(proc.stderr).text(),
|
|
30
|
+
proc.exited,
|
|
31
|
+
]);
|
|
32
|
+
return { code, stdout, stderr };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// Belt-and-suspenders: a spawn that slips past the pre-flight (binary
|
|
35
|
+
// removed between which() and spawn, or a race) still surfaces the
|
|
36
|
+
// friendly MissingDependencyError rather than the raw spawn throw.
|
|
37
|
+
if (binary) rethrowIfMissing(err, binary);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
export class TailscaleError extends Error {
|
package/src/totp.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* TOTP (RFC 6238) primitives + single-use backup codes for hub-login 2FA
|
|
4
|
+
* (hub#473). The pure crypto layer — no DB, no HTTP. The persistence layer
|
|
5
|
+
* (`two-factor-store.ts`) reads/writes these against `users` in hub.db; the
|
|
6
|
+
* login + enroll handlers compose both.
|
|
7
|
+
*
|
|
8
|
+
* Approach ported from `parachute-vault/src/two-factor.ts` (the deprecated
|
|
9
|
+
* vault impl), with two deliberate hub-side changes:
|
|
10
|
+
*
|
|
11
|
+
* - Storage is hub.db's `users` row, not vault's `config.yaml`. That lives
|
|
12
|
+
* in `two-factor-store.ts`; this file stays storage-agnostic.
|
|
13
|
+
* - Backup codes are hashed with **argon2id** (`@node-rs/argon2`), the same
|
|
14
|
+
* hasher hub uses for passwords (`users.ts`), rather than vault's bcrypt.
|
|
15
|
+
* One hash family across the hub keeps the dependency surface minimal and
|
|
16
|
+
* matches the brief ("same hash as passwords").
|
|
17
|
+
*
|
|
18
|
+
* TOTP parameters (interop default — what Google Authenticator / 1Password /
|
|
19
|
+
* Authy expect): SHA-1, 6 digits, 30s period. Validation accepts a ±1 window
|
|
20
|
+
* (≈90s effective tolerance) for clock drift. A given (secret, counter) is
|
|
21
|
+
* single-use within its acceptance lifetime — replays inside the window are
|
|
22
|
+
* rejected via an in-memory cache.
|
|
23
|
+
*/
|
|
24
|
+
import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
|
|
25
|
+
import * as OTPAuth from "otpauth";
|
|
26
|
+
|
|
27
|
+
/** Issuer label shown in the authenticator app + encoded in the otpauth URI. */
|
|
28
|
+
export const TOTP_ISSUER = "Parachute Hub";
|
|
29
|
+
/** Number of single-use backup codes minted per enrollment. */
|
|
30
|
+
export const BACKUP_CODE_COUNT = 10;
|
|
31
|
+
/** Length (characters) of each backup code. */
|
|
32
|
+
const BACKUP_CODE_LENGTH = 10;
|
|
33
|
+
/** TOTP secret size in bytes (20 = 160 bits, the RFC 6238 / RFC 4226 default). */
|
|
34
|
+
const TOTP_SECRET_BYTES = 20;
|
|
35
|
+
|
|
36
|
+
function makeTotp(secretBase32: string, label: string): OTPAuth.TOTP {
|
|
37
|
+
return new OTPAuth.TOTP({
|
|
38
|
+
issuer: TOTP_ISSUER,
|
|
39
|
+
label,
|
|
40
|
+
algorithm: "SHA1",
|
|
41
|
+
digits: 6,
|
|
42
|
+
period: 30,
|
|
43
|
+
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GeneratedSecret {
|
|
48
|
+
/** Base32-encoded secret — show to the user for manual authenticator entry. */
|
|
49
|
+
secret: string;
|
|
50
|
+
/** `otpauth://totp/...` URI — encode as a QR code for scanning. */
|
|
51
|
+
otpauthUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate a fresh TOTP secret + its `otpauth://` provisioning URI. `label`
|
|
56
|
+
* is the account label rendered in the authenticator app (typically the
|
|
57
|
+
* username). Does NOT persist anything — the caller stores the returned
|
|
58
|
+
* `secret` only after the user confirms a code (proving the authenticator
|
|
59
|
+
* was set up correctly).
|
|
60
|
+
*/
|
|
61
|
+
export function generateTotpSecret(label: string): GeneratedSecret {
|
|
62
|
+
const secret = new OTPAuth.Secret({ size: TOTP_SECRET_BYTES }).base32;
|
|
63
|
+
const totp = makeTotp(secret, label);
|
|
64
|
+
return { secret, otpauthUrl: totp.toString() };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build the `otpauth://` URI for an existing secret (e.g. re-display during enroll). */
|
|
68
|
+
export function otpauthUrlFor(secretBase32: string, label: string): string {
|
|
69
|
+
return makeTotp(secretBase32, label).toString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* In-memory cache of recently-used TOTP counters, to reject replay inside the
|
|
74
|
+
* ±1 acceptance window. Key = "sha256(secret):counter"; value = expiry ms.
|
|
75
|
+
* Bounded — entries auto-expire ~2 min after their window closes. Process-
|
|
76
|
+
* local (a restart clears it, which is itself fine: the window is 90s).
|
|
77
|
+
*/
|
|
78
|
+
const usedTotpCounters = new Map<string, number>();
|
|
79
|
+
|
|
80
|
+
function gcUsedTotp(now: number): void {
|
|
81
|
+
for (const [k, exp] of usedTotpCounters) {
|
|
82
|
+
if (exp < now) usedTotpCounters.delete(k);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify a 6-digit TOTP code against `secretBase32`. Accepts ±1 window
|
|
88
|
+
* (prev / current / next 30s period). A given (secret, counter) is single-use
|
|
89
|
+
* within its acceptance lifetime — replays are rejected.
|
|
90
|
+
*
|
|
91
|
+
* `markUsed`: set false in tests that want to verify the same code twice.
|
|
92
|
+
* Defaults to true in production so a captured code can't be replayed inside
|
|
93
|
+
* its ~90s validity window.
|
|
94
|
+
*/
|
|
95
|
+
export function verifyTotpCode(secretBase32: string, code: string, markUsed = true): boolean {
|
|
96
|
+
const trimmed = code.trim().replace(/\s+/g, "");
|
|
97
|
+
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
98
|
+
try {
|
|
99
|
+
const totp = makeTotp(secretBase32, "owner");
|
|
100
|
+
const delta = totp.validate({ token: trimmed, window: 1 });
|
|
101
|
+
if (delta === null) return false;
|
|
102
|
+
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
gcUsedTotp(now);
|
|
105
|
+
const counter = Math.floor(now / 30_000) + delta;
|
|
106
|
+
// Hash the secret so the in-memory replay cache never holds the plaintext
|
|
107
|
+
// TOTP secret as a map key (defense in depth against heap dumps / logs).
|
|
108
|
+
const secretHash = createHash("sha256").update(secretBase32).digest("hex");
|
|
109
|
+
const key = `${secretHash}:${counter}`;
|
|
110
|
+
if (usedTotpCounters.has(key)) return false;
|
|
111
|
+
if (markUsed) {
|
|
112
|
+
// Expire the entry a bit after the outer edge of the acceptance window.
|
|
113
|
+
usedTotpCounters.set(key, now + 120_000);
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test-only: reset the replay-protection cache between cases. */
|
|
122
|
+
export function _resetTotpReplayCache(): void {
|
|
123
|
+
usedTotpCounters.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Backup codes
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function randomBackupCode(): string {
|
|
131
|
+
// Lowercase alphanumeric minus ambiguous glyphs (0/o, 1/l/i). Read-aloud
|
|
132
|
+
// friendly + unambiguous when typed back in. Formatted as two 5-char groups
|
|
133
|
+
// (`abcde-fghij`) for legibility; the hyphen is cosmetic and stripped on
|
|
134
|
+
// verify so the user can type it with or without.
|
|
135
|
+
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
136
|
+
const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
|
|
137
|
+
let out = "";
|
|
138
|
+
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
|
|
139
|
+
out += alphabet[bytes[i]! % alphabet.length];
|
|
140
|
+
if (i === 4) out += "-";
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Normalize a backup code for hashing / comparison: lowercase, no whitespace, no hyphens. */
|
|
146
|
+
export function normalizeBackupCode(code: string): string {
|
|
147
|
+
return code
|
|
148
|
+
.trim()
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.replace(/[\s-]+/g, "");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface GeneratedBackupCodes {
|
|
154
|
+
/** Plaintext codes to show the user ONCE (hyphenated for display). */
|
|
155
|
+
codes: string[];
|
|
156
|
+
/** argon2id hashes of the normalized codes — what gets stored. */
|
|
157
|
+
hashes: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Generate {@link BACKUP_CODE_COUNT} fresh backup codes + their argon2id
|
|
162
|
+
* hashes. The plaintext `codes` are displayed once at enrollment; only the
|
|
163
|
+
* `hashes` are persisted (as a JSON array). Each code is hashed in its
|
|
164
|
+
* normalized form so display-formatting (hyphen) never affects verification.
|
|
165
|
+
*/
|
|
166
|
+
export async function generateBackupCodes(): Promise<GeneratedBackupCodes> {
|
|
167
|
+
const codes: string[] = [];
|
|
168
|
+
const hashes: string[] = [];
|
|
169
|
+
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
|
170
|
+
const code = randomBackupCode();
|
|
171
|
+
codes.push(code);
|
|
172
|
+
hashes.push(await argonHash(normalizeBackupCode(code)));
|
|
173
|
+
}
|
|
174
|
+
return { codes, hashes };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check a submitted backup code against a stored hash list. Returns the
|
|
179
|
+
* **index** of the matching hash (so the caller can splice it out and persist
|
|
180
|
+
* the shorter list — single-use consumption), or `-1` for no match.
|
|
181
|
+
*
|
|
182
|
+
* Pure: does NOT mutate the input list or persist anything. Consumption +
|
|
183
|
+
* persistence is the store layer's job (`two-factor-store.ts`), which holds
|
|
184
|
+
* the DB transaction so verify-then-consume is atomic against concurrent
|
|
185
|
+
* login attempts.
|
|
186
|
+
*/
|
|
187
|
+
export async function findBackupCodeIndex(
|
|
188
|
+
code: string,
|
|
189
|
+
hashes: readonly string[],
|
|
190
|
+
): Promise<number> {
|
|
191
|
+
const normalized = normalizeBackupCode(code);
|
|
192
|
+
if (!normalized) return -1;
|
|
193
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
194
|
+
try {
|
|
195
|
+
if (await argonVerify(hashes[i]!, normalized)) return i;
|
|
196
|
+
} catch {
|
|
197
|
+
// Corrupt / non-argon hash — skip.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/account/2fa` — user self-service TOTP 2FA enroll / disenroll (hub#473).
|
|
3
|
+
*
|
|
4
|
+
* GET /account/2fa — render current state (enrolled → status+disenroll;
|
|
5
|
+
* not enrolled → "set up" CTA).
|
|
6
|
+
* POST /account/2fa — dispatch on the `action` field:
|
|
7
|
+
* start → generate a secret + render QR/confirm
|
|
8
|
+
* confirm → verify code vs the in-flight secret,
|
|
9
|
+
* persist enrollment, show backup codes
|
|
10
|
+
* disable → verify current password, clear 2FA
|
|
11
|
+
*
|
|
12
|
+
* Auth posture: every endpoint requires an active session (the signed-in user
|
|
13
|
+
* acts on their OWN account). Same session-or-302-to-/login shape as
|
|
14
|
+
* `/account/change-password`.
|
|
15
|
+
*
|
|
16
|
+
* Enrollment is browser-first (right for headless servers): the secret is
|
|
17
|
+
* generated server-side, shown as a QR + manual base32 key, and only PERSISTED
|
|
18
|
+
* after the user confirms a live code (proving the authenticator was set up).
|
|
19
|
+
* The in-flight secret rides a hidden form field between `start` and `confirm`
|
|
20
|
+
* — it isn't stored until confirmation, so an abandoned setup leaves no state.
|
|
21
|
+
*
|
|
22
|
+
* No JSON layer — server-rendered HTML forms, same as the rest of `/account/*`,
|
|
23
|
+
* so 2FA setup works without JS.
|
|
24
|
+
*/
|
|
25
|
+
import type { Database } from "bun:sqlite";
|
|
26
|
+
import QRCode from "qrcode";
|
|
27
|
+
import { renderAdminError } from "./admin-login-ui.ts";
|
|
28
|
+
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
29
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
30
|
+
import { findActiveSession } from "./sessions.ts";
|
|
31
|
+
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
|
|
32
|
+
import {
|
|
33
|
+
backupCodesRemaining,
|
|
34
|
+
clearEnrollment,
|
|
35
|
+
getTotpState,
|
|
36
|
+
isTotpEnrolled,
|
|
37
|
+
persistEnrollment,
|
|
38
|
+
} from "./two-factor-store.ts";
|
|
39
|
+
import {
|
|
40
|
+
renderTwoFactorBackupCodes,
|
|
41
|
+
renderTwoFactorEnrolled,
|
|
42
|
+
renderTwoFactorEnrolling,
|
|
43
|
+
renderTwoFactorNotEnrolled,
|
|
44
|
+
} from "./two-factor-ui.ts";
|
|
45
|
+
import { PASSWORD_MAX_LEN, type User, getUserById, verifyPassword } from "./users.ts";
|
|
46
|
+
|
|
47
|
+
export interface TwoFactorDeps {
|
|
48
|
+
db: Database;
|
|
49
|
+
/** Test seam — defaults to real clock. */
|
|
50
|
+
now?: () => Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
54
|
+
return new Response(body, {
|
|
55
|
+
status,
|
|
56
|
+
headers: { "content-type": "text/html; charset=utf-8", ...extra },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function redirect(location: string, extra: Record<string, string> = {}): Response {
|
|
61
|
+
return new Response(null, { status: 302, headers: { location, ...extra } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the signed-in user, or a Response to return (302 / 401). */
|
|
65
|
+
function requireUser(deps: TwoFactorDeps, req: Request): { user: User } | { response: Response } {
|
|
66
|
+
const session = findActiveSession(deps.db, req);
|
|
67
|
+
if (!session) {
|
|
68
|
+
return { response: redirect(`/login?next=${encodeURIComponent("/account/2fa")}`) };
|
|
69
|
+
}
|
|
70
|
+
const user = getUserById(deps.db, session.userId);
|
|
71
|
+
if (!user) {
|
|
72
|
+
return { response: redirect("/login") };
|
|
73
|
+
}
|
|
74
|
+
return { user };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Render the QR SVG for a secret + the enrolling page. */
|
|
78
|
+
async function renderEnrolling(
|
|
79
|
+
csrfToken: string,
|
|
80
|
+
secret: string,
|
|
81
|
+
label: string,
|
|
82
|
+
errorMessage?: string,
|
|
83
|
+
): Promise<Response> {
|
|
84
|
+
const otpauthUrl = otpauthUrlFor(secret, label);
|
|
85
|
+
// Inline SVG — self-contained, no external fetch (privacy posture).
|
|
86
|
+
const qrSvg = await QRCode.toString(otpauthUrl, {
|
|
87
|
+
type: "svg",
|
|
88
|
+
margin: 0,
|
|
89
|
+
errorCorrectionLevel: "M",
|
|
90
|
+
});
|
|
91
|
+
return htmlResponse(
|
|
92
|
+
renderTwoFactorEnrolling({
|
|
93
|
+
csrfToken,
|
|
94
|
+
qrSvg,
|
|
95
|
+
secret,
|
|
96
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /account/2fa — render the current 2FA state for the signed-in user.
|
|
103
|
+
*/
|
|
104
|
+
export function handleTwoFactorGet(req: Request, deps: TwoFactorDeps): Response {
|
|
105
|
+
const got = requireUser(deps, req);
|
|
106
|
+
if ("response" in got) return got.response;
|
|
107
|
+
const user = got.user;
|
|
108
|
+
|
|
109
|
+
const csrf = ensureCsrfToken(req);
|
|
110
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
111
|
+
|
|
112
|
+
if (isTotpEnrolled(deps.db, user.id)) {
|
|
113
|
+
const state = getTotpState(deps.db, user.id);
|
|
114
|
+
return htmlResponse(
|
|
115
|
+
renderTwoFactorEnrolled({
|
|
116
|
+
csrfToken: csrf.token,
|
|
117
|
+
enrolledAt: state.enrolledAt,
|
|
118
|
+
backupCodesRemaining: state.backupCodes.length,
|
|
119
|
+
}),
|
|
120
|
+
200,
|
|
121
|
+
extra,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// `?disabled=1` — the disenroll POST 302s here so a refresh doesn't re-POST;
|
|
125
|
+
// surface a one-line confirmation on the not-enrolled page.
|
|
126
|
+
const disabled = new URL(req.url).searchParams.get("disabled") === "1";
|
|
127
|
+
return htmlResponse(
|
|
128
|
+
renderTwoFactorNotEnrolled({
|
|
129
|
+
csrfToken: csrf.token,
|
|
130
|
+
...(disabled ? { notice: "Two-factor authentication has been turned off." } : {}),
|
|
131
|
+
}),
|
|
132
|
+
200,
|
|
133
|
+
extra,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* POST /account/2fa — dispatch on `action`.
|
|
139
|
+
*/
|
|
140
|
+
export async function handleTwoFactorPost(req: Request, deps: TwoFactorDeps): Promise<Response> {
|
|
141
|
+
const got = requireUser(deps, req);
|
|
142
|
+
if ("response" in got) {
|
|
143
|
+
// For a POST without a session, a 302 to /login is fine (browser will
|
|
144
|
+
// follow with the form lost, which is acceptable — re-auth, then retry).
|
|
145
|
+
return got.response;
|
|
146
|
+
}
|
|
147
|
+
const user = got.user;
|
|
148
|
+
|
|
149
|
+
const form = await req.formData();
|
|
150
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
151
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
152
|
+
if (!verifyCsrfToken(req, csrfToken || null)) {
|
|
153
|
+
return htmlResponse(
|
|
154
|
+
renderAdminError({
|
|
155
|
+
title: "Invalid form submission",
|
|
156
|
+
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
157
|
+
}),
|
|
158
|
+
400,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const action = String(form.get("action") ?? "");
|
|
163
|
+
|
|
164
|
+
// --- start: generate a secret, render QR + confirm form ---------------
|
|
165
|
+
if (action === "start") {
|
|
166
|
+
// Refuse to start a fresh enrollment if already enrolled — disenroll
|
|
167
|
+
// first. Prevents accidentally clobbering a working setup.
|
|
168
|
+
if (isTotpEnrolled(deps.db, user.id)) {
|
|
169
|
+
return htmlResponse(
|
|
170
|
+
renderTwoFactorEnrolled({
|
|
171
|
+
csrfToken,
|
|
172
|
+
enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
|
|
173
|
+
backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
|
|
174
|
+
errorMessage: "Two-factor is already enabled. Turn it off first to re-enroll.",
|
|
175
|
+
}),
|
|
176
|
+
409,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const { secret } = generateTotpSecret(user.username);
|
|
180
|
+
return renderEnrolling(csrfToken, secret, user.username);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- confirm: verify code vs the in-flight secret, persist ------------
|
|
184
|
+
if (action === "confirm") {
|
|
185
|
+
const secret = String(form.get("secret") ?? "");
|
|
186
|
+
const code = String(form.get("code") ?? "");
|
|
187
|
+
// Validate the in-flight secret format before it reaches verifyTotpCode /
|
|
188
|
+
// persistEnrollment (N1 — cheap defense in depth). The secret is a
|
|
189
|
+
// server-minted base32 string round-tripped through a hidden form field;
|
|
190
|
+
// it's session-gated + CSRF'd, but a malformed value (truncated paste,
|
|
191
|
+
// hand-crafted POST) should be rejected explicitly rather than stored.
|
|
192
|
+
// base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars (our secret
|
|
193
|
+
// is 20 bytes → 32 base32 chars; 16 is a conservative floor).
|
|
194
|
+
if (!secret || !/^[A-Z2-7]+=*$/i.test(secret) || secret.length < 16) {
|
|
195
|
+
// Lost / malformed in-flight secret (stale form, bad paste) — restart.
|
|
196
|
+
return htmlResponse(
|
|
197
|
+
renderTwoFactorNotEnrolled({
|
|
198
|
+
csrfToken,
|
|
199
|
+
errorMessage: "Setup expired. Please start again.",
|
|
200
|
+
}),
|
|
201
|
+
400,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
// Defensive: someone POSTing confirm for an already-enrolled account.
|
|
205
|
+
if (isTotpEnrolled(deps.db, user.id)) {
|
|
206
|
+
return htmlResponse(
|
|
207
|
+
renderTwoFactorEnrolled({
|
|
208
|
+
csrfToken,
|
|
209
|
+
enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
|
|
210
|
+
backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
|
|
211
|
+
errorMessage: "Two-factor is already enabled.",
|
|
212
|
+
}),
|
|
213
|
+
409,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!verifyTotpCode(secret, code)) {
|
|
217
|
+
return renderEnrolling(
|
|
218
|
+
csrfToken,
|
|
219
|
+
secret,
|
|
220
|
+
user.username,
|
|
221
|
+
"That code didn't match. Make sure your device clock is correct and try the current code.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const result = await persistEnrollment(
|
|
225
|
+
deps.db,
|
|
226
|
+
user.id,
|
|
227
|
+
secret,
|
|
228
|
+
deps.now ?? (() => new Date()),
|
|
229
|
+
);
|
|
230
|
+
// Show the backup codes ONCE. Setting no-store so the browser doesn't
|
|
231
|
+
// cache the page with the plaintext codes in it.
|
|
232
|
+
return htmlResponse(renderTwoFactorBackupCodes({ codes: result.backupCodes }), 200, {
|
|
233
|
+
"cache-control": "no-store",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- disable: verify current password, clear 2FA ----------------------
|
|
238
|
+
if (action === "disable") {
|
|
239
|
+
if (!isTotpEnrolled(deps.db, user.id)) {
|
|
240
|
+
// Already off — render the not-enrolled page (idempotent).
|
|
241
|
+
return htmlResponse(
|
|
242
|
+
renderTwoFactorNotEnrolled({
|
|
243
|
+
csrfToken,
|
|
244
|
+
notice: "Two-factor authentication is already off.",
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const password = String(form.get("password") ?? "");
|
|
249
|
+
const state = getTotpState(deps.db, user.id);
|
|
250
|
+
const renderEnrolledError = (msg: string, status: number): Response =>
|
|
251
|
+
htmlResponse(
|
|
252
|
+
renderTwoFactorEnrolled({
|
|
253
|
+
csrfToken,
|
|
254
|
+
enrolledAt: state.enrolledAt,
|
|
255
|
+
backupCodesRemaining: state.backupCodes.length,
|
|
256
|
+
errorMessage: msg,
|
|
257
|
+
}),
|
|
258
|
+
status,
|
|
259
|
+
);
|
|
260
|
+
if (!password) {
|
|
261
|
+
return renderEnrolledError("Enter your current password to turn off two-factor.", 400);
|
|
262
|
+
}
|
|
263
|
+
// Cap before argon2id verify (CPU-DoS guard — same posture as /login).
|
|
264
|
+
if (password.length > PASSWORD_MAX_LEN) {
|
|
265
|
+
return renderEnrolledError(`Password must be ≤ ${PASSWORD_MAX_LEN} characters.`, 413);
|
|
266
|
+
}
|
|
267
|
+
const ok = await verifyPassword(user, password);
|
|
268
|
+
if (!ok) {
|
|
269
|
+
return renderEnrolledError("That password is incorrect.", 401);
|
|
270
|
+
}
|
|
271
|
+
clearEnrollment(deps.db, user.id, deps.now ?? (() => new Date()));
|
|
272
|
+
// Redirect to the GET so a refresh doesn't re-POST. The not-enrolled
|
|
273
|
+
// page renders the success notice via a query flag.
|
|
274
|
+
return redirect("/account/2fa?disabled=1", {
|
|
275
|
+
"cache-control": "no-store",
|
|
276
|
+
"x-secure-context": isHttpsRequest(req) ? "https" : "http",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return htmlResponse(
|
|
281
|
+
renderAdminError({
|
|
282
|
+
title: "Unknown action",
|
|
283
|
+
message: "That two-factor action isn't recognized. Reload the page and try again.",
|
|
284
|
+
}),
|
|
285
|
+
400,
|
|
286
|
+
);
|
|
287
|
+
}
|