mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
feat(haveibeenpwned): expose password compromise check (#11147)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
---
|
||||
|
||||
Add `isPasswordCompromised` for checking passwords against Have I Been Pwned in custom server-side flows, while ignoring padded response entries with zero occurrences.
|
||||
@@ -32,6 +32,21 @@ When a user attempts to create an account or update their password with a compro
|
||||
}
|
||||
```
|
||||
|
||||
### Custom password flows
|
||||
|
||||
Use `isPasswordCompromised` to check passwords in server-side flows that do not use Better Auth endpoints.
|
||||
|
||||
```ts
|
||||
import { isPasswordCompromised } from "better-auth/plugins/haveibeenpwned"
|
||||
|
||||
const compromised = await isPasswordCompromised(password)
|
||||
if (compromised) {
|
||||
throw new Error("Please choose a password that has not been compromised")
|
||||
}
|
||||
```
|
||||
|
||||
The function only sends the first five characters of the password's SHA-1 hash to Have I Been Pwned. It throws an `APIError` if the service cannot complete the check.
|
||||
|
||||
## Options
|
||||
|
||||
### `enabled`
|
||||
|
||||
@@ -7,7 +7,7 @@ import { emailOTP } from "../email-otp";
|
||||
import { emailOTPClient } from "../email-otp/client";
|
||||
import { phoneNumber } from "../phone-number";
|
||||
import { phoneNumberClient } from "../phone-number/client";
|
||||
import { haveIBeenPwned } from "./index";
|
||||
import { haveIBeenPwned, isPasswordCompromised } from "./index";
|
||||
|
||||
/**
|
||||
* Stubs the Have I Been Pwned range API so the given password is reported as
|
||||
@@ -298,3 +298,106 @@ describe("have-i-been-pwned", async () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://haveibeenpwned.com/API/v3#PwnedPasswords
|
||||
*/
|
||||
describe("isPasswordCompromised", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should report a compromised password", async () => {
|
||||
const password = "breached-password";
|
||||
await mockBreached(password);
|
||||
|
||||
await expect(isPasswordCompromised(password)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should report a safe password", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("00000000000000000000000000000000000:1\n", {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised("unique-password")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should ignore padded entries with a zero count", async () => {
|
||||
const password = "padded-password";
|
||||
const hash = (
|
||||
await createHash("SHA-1", "hex").digest(password)
|
||||
).toUpperCase();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(`${hash.slice(5)}:0\n`, { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised(password)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should match lowercase hash suffixes in CRLF responses", async () => {
|
||||
const password = "compromised-password";
|
||||
const hash = (
|
||||
await createHash("SHA-1", "hex").digest(password)
|
||||
).toUpperCase();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(`${hash.slice(5).toLowerCase()}:1\r\n`, { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised(password)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should check an empty password", async () => {
|
||||
await mockBreached("");
|
||||
|
||||
await expect(isPasswordCompromised("")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should throw an API error when the service cannot complete the check", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 503 }),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised("password")).rejects.toMatchObject({
|
||||
status: "INTERNAL_SERVER_ERROR",
|
||||
body: {
|
||||
message: "Failed to check password. Status: 503",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide unexpected service errors", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("network unavailable"),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised("password")).rejects.toMatchObject({
|
||||
status: "INTERNAL_SERVER_ERROR",
|
||||
body: {
|
||||
message: "Failed to check password. Please try again later.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
"invalid",
|
||||
])("should reject an invalid compromise count %#", async (compromiseCount) => {
|
||||
const password = "password";
|
||||
const hash = (
|
||||
await createHash("SHA-1", "hex").digest(password)
|
||||
).toUpperCase();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(`${hash.slice(5)}:${compromiseCount}\n`, { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(isPasswordCompromised(password)).rejects.toMatchObject({
|
||||
status: "INTERNAL_SERVER_ERROR",
|
||||
body: {
|
||||
message: "Failed to check password. Please try again later.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,18 +20,44 @@ const ERROR_CODES = defineErrorCodes({
|
||||
"The password you entered has been compromised. Please choose a different password.",
|
||||
});
|
||||
|
||||
async function checkPasswordCompromise(
|
||||
password: string,
|
||||
customMessage?: string | undefined,
|
||||
) {
|
||||
if (!password) return;
|
||||
function getPasswordCompromiseCount(response: string, hashSuffix: string) {
|
||||
const matchingEntryPrefix = `${hashSuffix.toUpperCase()}:`;
|
||||
|
||||
const sha1Hash = (
|
||||
await createHash("SHA-1", "hex").digest(password)
|
||||
).toUpperCase();
|
||||
const prefix = sha1Hash.substring(0, 5);
|
||||
const suffix = sha1Hash.substring(5);
|
||||
for (const line of response.split(/\r?\n/)) {
|
||||
const entryPrefix = line.slice(0, matchingEntryPrefix.length).toUpperCase();
|
||||
if (entryPrefix !== matchingEntryPrefix) continue;
|
||||
|
||||
const compromiseCountText = line.slice(matchingEntryPrefix.length);
|
||||
const compromiseCount = Number(compromiseCountText);
|
||||
const validCompromiseCount =
|
||||
Number.isSafeInteger(compromiseCount) &&
|
||||
compromiseCount >= 0 &&
|
||||
String(compromiseCount) === compromiseCountText;
|
||||
if (!validCompromiseCount) {
|
||||
throw new Error("Invalid password compromise count");
|
||||
}
|
||||
return compromiseCount;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a password appears in the Have I Been Pwned password corpus.
|
||||
* Only the first five characters of its SHA-1 hash are sent to the service.
|
||||
*
|
||||
* @returns Whether the password has been compromised.
|
||||
* @throws {APIError} When the password could not be checked.
|
||||
*/
|
||||
export async function isPasswordCompromised(
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sha1Hash = (
|
||||
await createHash("SHA-1", "hex").digest(password)
|
||||
).toUpperCase();
|
||||
const prefix = sha1Hash.substring(0, 5);
|
||||
const suffix = sha1Hash.substring(5);
|
||||
const { data, error } = await betterFetch<string>(
|
||||
`https://api.pwnedpasswords.com/range/${prefix}`,
|
||||
{
|
||||
@@ -47,17 +73,8 @@ async function checkPasswordCompromise(
|
||||
message: `Failed to check password. Status: ${error.status}`,
|
||||
});
|
||||
}
|
||||
const lines = data.split("\n");
|
||||
const found = lines.some(
|
||||
(line) => line.split(":")[0]!.toUpperCase() === suffix.toUpperCase(),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
throw APIError.from("BAD_REQUEST", {
|
||||
message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message,
|
||||
code: ERROR_CODES.PASSWORD_COMPROMISED.code,
|
||||
});
|
||||
}
|
||||
const compromiseCount = getPasswordCompromiseCount(data, suffix);
|
||||
return compromiseCount > 0;
|
||||
} catch (error) {
|
||||
if (isAPIError(error)) throw error;
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
@@ -66,6 +83,19 @@ async function checkPasswordCompromise(
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectCompromisedPassword(
|
||||
password: string,
|
||||
customMessage?: string | undefined,
|
||||
) {
|
||||
const compromised = await isPasswordCompromised(password);
|
||||
if (compromised) {
|
||||
throw APIError.from("BAD_REQUEST", {
|
||||
message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message,
|
||||
code: ERROR_CODES.PASSWORD_COMPROMISED.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface HaveIBeenPwnedOptions {
|
||||
/**
|
||||
* Custom error message shown when a compromised password is detected.
|
||||
@@ -112,7 +142,7 @@ export const haveIBeenPwned = (options?: HaveIBeenPwnedOptions | undefined) => {
|
||||
if (!c.path || !paths.includes(c.path)) {
|
||||
return originalHash(password);
|
||||
}
|
||||
await checkPasswordCompromise(
|
||||
await rejectCompromisedPassword(
|
||||
password,
|
||||
options?.customPasswordCompromisedMessage,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user