mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user