fix(auth): wrong passwords from elsewhere can no longer lock the owner out

The per-account password limit refused every attempt once ten failures
had landed in fifteen minutes, counted before the password was checked.
Ten guesses from two addresses therefore locked a password-only account,
the app's password sign-in included, and repeating them kept it locked
for as long as anyone cared to.

A place the account has signed in from before is now neither counted nor
held back: a remembered browser, a phone presenting the device id a login
of this account was issued to, or an address a session or device login of
this account came from in the last thirty days. Anywhere else gets five
free failures a day and then a wait that doubles with each failure, from
thirty seconds to at most fifteen minutes, instead of a wall. The per-IP
limit is unchanged, and an unknown identifier is limited the same way on
its hash.

Under an SSO-only policy, a password sign-in to an account other than the
one a pending link names is now refused before the password is checked,
so that exception no longer tells right passwords from wrong ones.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:42:40 +02:00
1 parent 1900792756
commit 995bd2385a
11 files changed
+527 -57

No files matched your search

+14 -10
View File
@@ -9,6 +9,11 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
// The per-account throttle's reads: no known place, no wait running.
trustedDevice: { findUnique: vi.fn(async () => null) },
refreshToken: { findFirst: vi.fn(async () => null) },
session: { findFirst: vi.fn(async () => null) },
rateLimit: { findUnique: vi.fn(async () => null), upsert: vi.fn() },
},
}));
vi.mock("@/lib/auth/password", () => ({
@@ -165,27 +170,26 @@ describe("login MFA branch", () => {
});
});
describe("per-account login ceiling", () => {
describe("per-account login throttle", () => {
it("refuses a throttled account before any password is verified", async () => {
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 10,
remaining: 0,
resetAt: Date.now() + 1e6,
});
// A wait is running for this account, set by earlier failures.
vi.mocked(prisma.rateLimit.findUnique).mockResolvedValueOnce({
resetAt: new Date(Date.now() + 60_000),
} as never);
const res = await POST(loginRequest());
expect(res.status).toBe(429);
expect(verifyPasswordOrDummy).not.toHaveBeenCalled();
expect(finishLogin).not.toHaveBeenCalled();
expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toBe(
"auth:login:account:u:user-1",
);
expect(vi.mocked(prisma.rateLimit.findUnique).mock.calls[0][0]).toEqual({
where: { key: "auth:login:account:u:user-1:wait" },
select: { resetAt: true },
});
});
it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => {