Files
HealthLog/src/__tests__/api/auth/login/mfa-branch.test.ts
T
Marc-André Bombeck d7b5fcd6c2 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.
2026-09-26 20:10:12 +02:00

216 lines
6.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/auth/mfa-enrollment", () => ({
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPasswordOrDummy: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 1e6,
ip: "203.0.113.7",
}),
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ data: { user: { id: "user-1" } }, error: null }),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
),
}));
vi.mock("@/lib/auth/mfa/challenge", () => ({
createMfaChallenge: vi.fn().mockResolvedValue({
ticket: "opaque-ticket-value",
expiresAt: new Date(Date.now() + 5 * 60_000),
}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
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", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "user@example.com",
password: "correct-horse-battery",
}),
}) as unknown as Parameters<typeof POST>[0];
}
const BASE_USER = {
id: "user-1",
username: "u",
email: "user@example.com",
passwordHash: "argon2-hash",
onboardingCompletedAt: new Date(),
};
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", () => {
it("MFA user → returns mfaRequired + ticket and issues NO session", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: new Date(),
} as never);
const res = await POST(loginRequest());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toBeNull();
expect(body.error).toBeNull();
expect(body.meta.mfaRequired).toBe(true);
expect(body.meta.mfaTicket).toBe("opaque-ticket-value");
expect(body.meta.methods).toEqual(["totp", "recovery"]);
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
// The session/token tail is never reached.
expect(finishLogin).not.toHaveBeenCalled();
});
it("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
const res = await POST(loginRequest());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.mfaRequired).toBe(true);
expect(body.meta.methods).toEqual(["webauthn"]);
expect(finishLogin).not.toHaveBeenCalled();
});
it("non-MFA user → issues the session via finishLogin, no challenge", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
const res = await POST(loginRequest());
expect(res.status).toBe(200);
expect(finishLogin).toHaveBeenCalledTimes(1);
expect(createMfaChallenge).not.toHaveBeenCalled();
const arg = vi.mocked(finishLogin).mock.calls[0][0];
expect(arg.source).toBe("login.password");
expect(arg.mfaVerified).toBeUndefined();
});
it("wrong password never reaches the MFA branch", async () => {
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: new Date(),
} as never);
const res = await POST(loginRequest());
expect(res.status).toBe(401);
expect(createMfaChallenge).not.toHaveBeenCalled();
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();
});
});