mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(auth): require a fresh proof before a second factor is added
A browser session on its own could start TOTP enrollment or register a security key. Combined with a pending secret counting as a factor, that let a stolen session give itself a second factor, register a passkey with it, and keep access after the owner changed the password. TOTP setup and security-key registration now pass on the cookie path only when the session signed in or completed a second factor in the last five minutes, or when the body carries a proof in the step-up shape (password, a confirmed authenticator code, or a passkey or security-key assertion). The settings cards ask for that proof when the server answers auth.reproof.required and retry the same call with it. The Bearer path is unchanged: its elevation already is the proof. Every route that checks a password or a factor as a re-proof now uses one verifier and one per-account budget of five attempts per fifteen minutes, shared with the step-up mint, and records each refused proof as auth.mfa.failed. Passkey enrollment and password change had neither the limit (passkey enrollment) nor the audit row (both). A structural test freezes the set of credential-verifying routes and requires a throttle and an audit call in each.
This commit is contained in:
1 parent
e1718e3c30
commit
7b5bc130d8
27 files changed
+1763
-584
No files matched your search
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TOTP and security-key enrollment ask for a fresh proof when the server says
|
||||
* so (401 `auth.reproof.required`), and retry the SAME enrollment call with it.
|
||||
*
|
||||
* Source-oriented like the passkey reauth test next door: the repository keeps
|
||||
* component tests free of a browser DOM harness. The behavioural half — that
|
||||
* the server refuses without the proof — is pinned against Postgres in
|
||||
* `tests/integration/credential-enrollment-proof.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { isReproofRequired } from "../existing-factor-reauth-dialog";
|
||||
|
||||
const read = (file: string) =>
|
||||
readFileSync(resolve(__dirname, "..", file), "utf8");
|
||||
|
||||
describe("isReproofRequired", () => {
|
||||
it("recognises only the re-proof refusal", () => {
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.required" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.failed" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.stepup.required" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isReproofRequired(new Error("x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["totp-card.tsx", '"/api/auth/me/mfa/totp/setup"', "beginSetup"],
|
||||
[
|
||||
"security-keys-card.tsx",
|
||||
'"/api/auth/me/mfa/webauthn/register/options"',
|
||||
"add",
|
||||
],
|
||||
])("%s", (file, endpoint, mutation) => {
|
||||
const source = read(file);
|
||||
|
||||
it("sends the collected proof on the enrollment call itself", () => {
|
||||
expect(source).toContain(endpoint);
|
||||
expect(source).toMatch(
|
||||
new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")},\\s*proof\\)`),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the re-proof dialog on the server's cue and retries with the proof", () => {
|
||||
expect(source).toContain("isReproofRequired(err)");
|
||||
expect(source).toContain("setReauthOpen(true)");
|
||||
expect(source).toContain(`onProof={(proof) => ${mutation}.mutate(proof)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server answers an enrollment it will not take on the session alone with
|
||||
* 401 `auth.reproof.required`. That is the cue to ask for a proof and retry,
|
||||
* not an error to show.
|
||||
*/
|
||||
export function isReproofRequired(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
err.status === 401 &&
|
||||
err.meta?.errorCode === "auth.reproof.required"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
|
||||
* an assertion challenge and nothing else; the resulting proof is handed to
|
||||
* `onProof`, which retries the enrollment call with it.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
methods,
|
||||
pending,
|
||||
error,
|
||||
onProof,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
methods: ReauthMethod[];
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onProof: (proof: ExistingFactorProof) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [method, setMethod] = useState<ReauthMethod>(methods[0] ?? "password");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [assertionError, setAssertionError] = useState<string | null>(null);
|
||||
const [asserting, setAsserting] = useState(false);
|
||||
const busy = pending || asserting;
|
||||
const shown = assertionError ?? error;
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
}
|
||||
if (method === "totp") {
|
||||
onProof({ method, code: secret });
|
||||
return;
|
||||
}
|
||||
setAsserting(true);
|
||||
try {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
const begun = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: begun.options,
|
||||
});
|
||||
onProof({ method, challengeId: begun.challengeId, credential });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setAssertionError(err.message);
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setAssertionError(t(key, params));
|
||||
}
|
||||
} finally {
|
||||
setAsserting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (busy) return;
|
||||
if (!next) {
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-testid="factor-reauth-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.passkeyReauth.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.passkeyReauth.factorDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{methods.length > 1 && (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{methods.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
variant={method === m ? "secondary" : "outline"}
|
||||
aria-pressed={method === m}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMethod(m);
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}}
|
||||
>
|
||||
{labels[m]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
data-testid="factor-reauth-password"
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
placeholder={t("settings.passkeyReauth.currentPassword")}
|
||||
aria-label={t("settings.passkeyReauth.currentPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{method === "totp" && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={secret}
|
||||
onChange={(event) =>
|
||||
setSecret(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder={t("settings.passkeyReauth.authenticatorPlaceholder")}
|
||||
aria-label={t("settings.passkeyReauth.authenticatorCode")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{shown}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="factor-reauth-submit"
|
||||
disabled={
|
||||
busy ||
|
||||
((method === "password" || method === "totp") &&
|
||||
secret.length === 0)
|
||||
}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,10 @@ export function SecuritySection() {
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
<SecurityKeysCard
|
||||
keys={status.webauthn}
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,12 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
@@ -74,20 +80,38 @@ export function describeStepUp(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
export function SecurityKeysCard({
|
||||
keys,
|
||||
totpEnabled = false,
|
||||
}: {
|
||||
keys: WebauthnKeyInfo[];
|
||||
totpEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
// Adding a key right after signing in goes straight through; later, the
|
||||
// server asks for a fresh proof first (401 `auth.reproof.required`) and the
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
];
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/me/mfa/webauthn/register/options");
|
||||
}>("/api/auth/me/mfa/webauthn/register/options", proof);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
|
||||
challengeId,
|
||||
@@ -96,14 +120,28 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("settings.security.keys.addFailed"));
|
||||
message = err.message || t("settings.security.keys.addFailed");
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
message = t(key, params);
|
||||
}
|
||||
if (proof) {
|
||||
setReauthError(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -294,7 +332,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => add.mutate()}
|
||||
onClick={() => add.mutate(undefined)}
|
||||
disabled={add.isPending}
|
||||
>
|
||||
{add.isPending ? (
|
||||
@@ -305,6 +343,17 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
{t("settings.security.keys.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={reauthMethods}
|
||||
pending={add.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => add.mutate(proof)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
otpauthUri: string;
|
||||
@@ -118,12 +123,20 @@ export function TotpCard({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Enrollment right after signing in goes straight through; later, the server
|
||||
// asks for a fresh proof first (401 `auth.reproof.required`), and the setup
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup", proof);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
setSetup(data);
|
||||
try {
|
||||
const QRCode = (await import("qrcode")).default;
|
||||
@@ -133,14 +146,21 @@ export function TotpCard({
|
||||
setQrDataUrl(null);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
);
|
||||
if (proof) setReauthError(message);
|
||||
else setError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
@@ -241,7 +261,7 @@ export function TotpCard({
|
||||
type="button"
|
||||
data-testid="totp-setup-start"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate()}
|
||||
onClick={() => beginSetup.mutate(undefined)}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
@@ -252,6 +272,17 @@ export function TotpCard({
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
)}
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
/>
|
||||
|
||||
{/* ── Enable wizard ── */}
|
||||
{setup && (
|
||||
|
||||
Reference in new issue
Block a user