Files
HealthLog/src/app/api/auth/password/route.ts
T
Marc-André Bombeck 5ea17398bc feat(security): encrypt medication free-text notes at rest + password-change MFA step-up
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.

Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
2026-06-28 03:05:10 +02:00

125 lines
3.8 KiB
TypeScript

import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
verifyPassword,
hashPassword,
checkPasswordStrength,
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
// v1.25 — for an account with a second factor active a password change
// requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition
// to the current-password re-proof below, so a hijacked live session cannot
// rotate the credential. Accounts without MFA keep the current-password-only
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`auth:password:${user.id}`,
5,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const strength = checkPasswordStrength(
newPassword,
[user.username, user.email ?? ""],
locale,
);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
// v1.23 — reject a newly chosen password that appears in a known breach
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
// never blocks the change. Only the user's chosen NEW password is checked —
// existing credentials are never retroactively blocked at login.
const breach = await checkPasswordBreach(newPassword);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
// Invalidate all existing sessions and create a fresh one.
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
// `hl_onboarding` cookie itself.
await destroyAllSessions(user.id);
await createSession(
user.id,
user.onboardingCompletedAt == null,
getClientIp(request),
request.headers.get("user-agent"),
);
await auditLog("auth.password.change", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.password.change" } });
return apiSuccess({ changed: true });
});