mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -18,7 +18,12 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -42,7 +47,10 @@ export const GET = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -45,7 +50,10 @@ const handler = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
HttpError,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
@@ -143,7 +148,10 @@ async function* decodedBytes(
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
annotate({ action: { name: "admin.backups.upload" } });
|
||||
const ipAddress = getClientIp(request);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
const { user, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
annotate({ action: { name: "admin.data.delete" } });
|
||||
|
||||
// v1.18.1 — the documented convention buckets authenticated admin
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -31,7 +35,10 @@ export const POST = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
const { user, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
// data, so a stolen admin session alone must not be enough.
|
||||
await assertRecentCookieProof(user, session.id);
|
||||
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user