mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(auth): ask for a fresh proof before the record or a credential to it leaves
Exporting the whole record, making a clinician share link, minting a token, and the admin backup download, upload and restore, data wipe and password reset ran on a live session alone. The plaintext full backup did, while the encrypted one beside it asked for a second factor. Whoever held a stolen session could take the record, or leave behind a credential that outlives the session. These now go through one gate, requireRecentProof (assertRecentCookieProof for the routes that resolve their caller themselves). A browser session passes after a sign-in or re-proof within five minutes: a second factor on an account that has one, otherwise the password or a passkey. The refusal is 401 auth.reproof.required with meta.methods, and the web opens the re-proof dialog, posts the proof to the new POST /api/auth/reproof and retries. A password proof stamps the new Session.reproofAt (migration 0359), never mfaVerifiedAt, so it cannot stand in for a second factor anywhere. On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted export asks for a second-factor elevation on an account that has one. Share links and connector tokens keep taking the token alone, because the shipped app calls them without an elevation and reads a 401 there as a dead session; the guard test names them. Key rotation keeps its stricter second-factor gate.
This commit is contained in:
1 parent
a7f66b8519
commit
0fc83cb0a1
56 files changed
+2028
-235
No files matched your search
@@ -12,6 +12,15 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
passkey: { count: vi.fn(async () => 0) },
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
|
||||
@@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({
|
||||
consentReceipt: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: { findUnique: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
session: {
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
})),
|
||||
},
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
userAchievement: {
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
|
||||
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -114,6 +115,14 @@ beforeEach(() => {
|
||||
// Default: no registered security key. Tests that exercise the
|
||||
// WebAuthn-only cohort override this.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
// Default: a session that signed in a moment ago. The cases about the
|
||||
// recent-proof gate set their own stamps.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/export/encrypted", () => {
|
||||
@@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a single-factor account whose session is not recent (401 reproof)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.required");
|
||||
});
|
||||
|
||||
it("allows a single-factor account after a password re-proof", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
mfaVerifiedAt: null,
|
||||
reproofAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Step-up: when the account has any second factor enrolled (TOTP OR a
|
||||
* security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
|
||||
* whole record is a sensitive action. Single-factor accounts fall back to a
|
||||
* normal authenticated session.
|
||||
* - Fresh proof: `requireRecentProof`, the gate every whole-record export
|
||||
* shares. A second factor within five minutes on an account that has one;
|
||||
* otherwise a recent sign-in or a password re-proof.
|
||||
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
|
||||
* body, passed straight into the KDF, and never `annotate()`d. The egress
|
||||
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
|
||||
@@ -20,19 +19,16 @@
|
||||
* - There is NO server-side recovery — the passphrase is not stored. A
|
||||
* forgotten passphrase means the archive is unrecoverable; the UI says so.
|
||||
*
|
||||
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
|
||||
* second factor for MFA accounts (Bearer can never satisfy step-up).
|
||||
* Auth: cookie session with a recent proof; Bearer on the token alone for an
|
||||
* account without a second factor, with a second-factor `X-Step-Up`
|
||||
* elevation for one with.
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
|
||||
* plaintext export so the encrypted variant cannot be used to bypass the cap.
|
||||
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -92,13 +88,13 @@ const encryptedExportSchema = z
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Resolve the session (cookie or Bearer), then escalate to a fresh second
|
||||
// factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
|
||||
// covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
|
||||
// security key — so a security-key-only account is gated too. A single-factor
|
||||
// account cannot produce a fresh-MFA proof, so it passes straight through
|
||||
// rather than being locked out of its own encrypted export.
|
||||
const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// A fresh proof, like every whole-record export (`requireRecentProof`). On
|
||||
// the cookie path: a second factor within five minutes on an account that
|
||||
// has one, otherwise a recent sign-in or password re-proof. On Bearer the
|
||||
// shipped app calls this route without an elevation, so an account without
|
||||
// a second factor still passes on its token; one with a second factor
|
||||
// presents a second-factor elevation (before, it could not export at all).
|
||||
const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" });
|
||||
const user = auth.user;
|
||||
annotate({ action: { name: "user.export.encrypted" } });
|
||||
|
||||
@@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
await auth.commitElevation();
|
||||
|
||||
// Sealed as it is produced and spooled to a temporary file, then sent: the
|
||||
// format puts the tag in front of the ciphertext, so the archive cannot go
|
||||
|
||||
@@ -25,12 +25,16 @@
|
||||
* included; the payload's `manifest` field discloses both, and the export UI
|
||||
* states this too.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
|
||||
* plain text, so a live session alone is not enough: a cookie session needs a
|
||||
* recent sign-in or re-proof (a second factor on an account that has one), a
|
||||
* Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
|
||||
* on Bearer.
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.full-backup` with the row counts.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireRecentProof({ bearer: "elevation" });
|
||||
const { user } = auth;
|
||||
annotate({ action: { name: "user.export.full-backup" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
await auth.commitElevation();
|
||||
|
||||
// Written into the response as it is produced (`streamFullBackupJson`), not
|
||||
// built and stringified first: for an account of 1.25 million measurements
|
||||
|
||||
Reference in new issue
Block a user