fix(auth): cap failed password sign-ins per account, not only per address

Password sign-in was limited per client address only, so guesses spread
over many addresses reached one account without end. Each account now has
its own ceiling of ten failed attempts in fifteen minutes, whichever
addresses they come from. The attempt is counted before the password is
hashed and given back when the password is right, so the owner signing in
does not use it up. An identifier that matches no account is counted under
its hash with the same ceiling, so the refusal does not reveal whether the
account exists. Passkey and single sign-on remain open while the ceiling
is reached.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 20:10:12 +02:00
1 parent bc69dec8e6
commit d7b5fcd6c2
2 files changed
+112 -1

No files matched your search

@@ -29,6 +29,8 @@ vi.mock("@/lib/rate-limit", () => ({
resetAt: Date.now() + 1e6,
ip: "203.0.113.7",
}),
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/login-response", () => ({
@@ -61,6 +63,7 @@ import { POST } from "@/app/api/auth/login/route";
import { prisma } from "@/lib/db";
import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
function loginRequest() {
return new Request("http://localhost/api/auth/login", {
@@ -85,6 +88,12 @@ beforeEach(() => {
vi.clearAllMocks();
// Default: no registered security key. Individual tests override as needed.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 10,
remaining: 9,
resetAt: Date.now() + 1e6,
});
});
describe("login MFA branch", () => {
@@ -155,3 +164,52 @@ describe("login MFA branch", () => {
expect(finishLogin).not.toHaveBeenCalled();
});
});
describe("per-account login ceiling", () => {
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,
});
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",
);
});
it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
const res = await POST(loginRequest());
expect(res.status).toBe(401);
expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toMatch(
/^auth:login:account:i:/,
);
});
it("refunds a successful sign-in and keeps a failed one counted", async () => {
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
await POST(loginRequest());
expect(refundRateLimit).toHaveBeenCalledWith("auth:login:account:u:user-1");
vi.mocked(refundRateLimit).mockClear();
vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
await POST(loginRequest());
expect(refundRateLimit).not.toHaveBeenCalled();
});
});