feat(auth): warn on breached passwords at password set

Check a newly chosen password against HaveIBeenPwned through the k-anonymity
range API: only the first five hex characters of the SHA-1 digest leave the
host, the suffix is matched locally, and Add-Padding hides the bucket size.
A confirmed match is rejected so a fresh credential is never a known-breached
one; the check fails open, so an unreachable HIBP never blocks the operation.
Covers registration, password change, and admin reset. Existing passwords are
never retroactively blocked at login.

Also lands the six-locale strings for the password-breach notice and the
account-security surfaces that follow.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:20 +02:00
1 parent 01689f77bc
commit b882698a44
10 files changed
+427 -18

No files matched your search

@@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
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 (
@@ -65,6 +67,16 @@ export const POST = apiHandler(
);
}
// v1.23 — reject an admin-set password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },