Merge branch 'fix/v1439' into release/v1.43.9

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 22:49:33 +02:00
commit b4618f2ec3
55 files changed
+1547 -270

No files matched your search

@@ -34,6 +34,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
verifyMfaFactor: vi.fn(),
loadFactorUser: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -42,7 +43,7 @@ import { POST } from "../route";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
function req() {
return new Request("http://localhost/api/auth/me/mfa/disable", {
@@ -54,6 +55,12 @@ function req() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadFactorUser).mockResolvedValue({
id: "u1",
totpSecretEncrypted: "enc",
totpLastStep: null,
totpConfirmedAt: new Date("2026-01-01"),
});
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { id: "u1" },
+5 -6
View File
@@ -30,7 +30,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
@@ -72,11 +72,10 @@ export const POST = apiHandler(async (req: Request) => {
);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
const factorUser = await loadFactorUser(user.id);
const factor = factorUser
? await verifyMfaFactor(factorUser, parsed.data.method, parsed.data.code)
: { ok: false, replay: false };
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
@@ -37,6 +37,7 @@ import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import { loadFactorUser } from "@/lib/auth/mfa/verify-factor";
import {
generateRecoveryCodes,
persistRecoveryCodes,
@@ -69,7 +70,9 @@ export const POST = apiHandler(async (req: Request) => {
if (user.totpConfirmedAt) {
throw new HttpError(409, "A second factor is already active");
}
if (!user.totpSecretEncrypted) {
// The pending secret and its replay floor are not on the per-request user.
const pending = await loadFactorUser(user.id);
if (!pending?.totpSecretEncrypted) {
throw new HttpError(409, "Start enrollment before confirming");
}
@@ -90,11 +93,11 @@ export const POST = apiHandler(async (req: Request) => {
// Decrypt is fail-closed — a missing/rotated key throws rather than
// confirming against junk.
const secret = decrypt(user.totpSecretEncrypted);
const secret = decrypt(pending.totpSecretEncrypted);
const result = verifyTotp(
secret,
parsed.data.code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
pending.totpLastStep === null ? null : Number(pending.totpLastStep),
);
if (!result.valid) {
+10 -2
View File
@@ -162,6 +162,7 @@ export const GET = apiHandler(async () => {
onboardingRow,
ai,
coachReasoning,
identity,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -235,6 +236,13 @@ export const GET = apiHandler(async () => {
recordId,
authority: aiAuthority,
}),
// The encrypted insurance number is not on the per-request user
// (`SESSION_USER_SELECT`); it is read here, for the one response that
// shows it.
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true },
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -286,9 +294,9 @@ export const GET = apiHandler(async () => {
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (user.insuranceNumberEncrypted) {
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(user.insuranceNumberEncrypted);
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
@@ -21,6 +21,7 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
passkey: {
findUnique: vi.fn(),
update: vi.fn(),
@@ -40,7 +41,9 @@ import { DELETE, PATCH } from "../route";
import { requireAuth, requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
const USER = { id: "user-1", username: "u" };
/** The stored credential row; the session user carries no hash. */
const STORED = { passwordHash: "argon2id$…" };
const ROW = {
id: "pk-1",
@@ -75,6 +78,7 @@ const commitElevation = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(STORED as never);
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
commitElevation.mockClear().mockResolvedValue(undefined);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
@@ -256,9 +260,12 @@ describe("DELETE /api/auth/passkeys/{id} — the removal's gate", () => {
name: "Only one",
} as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: null,
} as never);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { ...USER, passwordHash: null },
user: USER,
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
commitElevation,
} as never);
+8 -4
View File
@@ -128,10 +128,14 @@ export const DELETE = apiHandler(
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
const [passkeyCount, credential] = await Promise.all([
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
}),
]);
const hasPassword = !!credential?.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
@@ -20,7 +20,7 @@ vi.mock("@/lib/api-handler", async () => {
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
user: { update: vi.fn(), findUnique: vi.fn() },
},
}));
@@ -92,7 +92,6 @@ const SESSION_OK = {
username: "tester",
email: "t@example.com",
role: "USER" as const,
passwordHash: "h",
onboardingCompletedAt: new Date(),
locale: "en",
},
@@ -109,6 +108,10 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// The hash is read off the stored row, not the session user.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: "h",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// Default: account clears the step-up (no MFA enrolled, or a fresh factor).
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never);
+9 -2
View File
@@ -58,11 +58,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
const credential = await prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
});
if (!credential?.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
const currentValid = await verifyPassword(
credential.passwordHash,
currentPassword,
);
if (!currentValid) {
await recordReproofFailure(
user.id,