mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'fix/v1439' into release/v1.43.9
This commit is contained in:
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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user