mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
1900792756
commit
995bd2385a
11 files changed
+527
-57
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user