mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+12
-1
@@ -1865,6 +1865,15 @@ paths:
|
||||
200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the
|
||||
`meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token
|
||||
bundle. Accounts without MFA are unchanged.
|
||||
|
||||
|
||||
Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a
|
||||
place the account has not signed in from before make the next attempt wait: five failures a day are free, then
|
||||
each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in
|
||||
from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the
|
||||
`X-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. Both answer 429 with `Retry-After`. An identifier that names no account is
|
||||
limited the same way, so a 429 says nothing about whether it exists.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1884,7 +1893,9 @@ paths:
|
||||
"403":
|
||||
description: "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in
|
||||
through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to
|
||||
confirm an existing account: it may sign in with that one account's password, which links the account."
|
||||
confirm an existing account: it may sign in with that one account's password, which links the account. A
|
||||
sign-in to any other account on that browser is refused before its password is checked, so the answer is the
|
||||
same whether the password was right or wrong."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -3,6 +3,16 @@ import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The per-account throttle has its own suite (login-throttle.test.ts); here
|
||||
// it lets every attempt through and counts nothing.
|
||||
vi.mock("@/lib/auth/login-throttle", () => ({
|
||||
beginAccountLoginAttempt: vi.fn(async () => ({
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {},
|
||||
succeeded: async () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
|
||||
@@ -9,12 +9,8 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -26,15 +22,6 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
/**
|
||||
* Failed password sign-ins one account takes per window, from any number of
|
||||
* addresses. Twice the per-IP ceiling, so the owner mistyping from one network
|
||||
* meets that one first, and small enough that guessing is hopeless.
|
||||
*/
|
||||
const LOGIN_ACCOUNT_BUCKET = "auth:login:account";
|
||||
const LOGIN_ACCOUNT_LIMIT = 10;
|
||||
const LOGIN_ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
@@ -105,27 +92,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// A per-account ceiling beside the per-IP one. The IP bucket alone is only
|
||||
// as good as the address it is keyed on, and a guesser with many addresses
|
||||
// (or a proxy that forwards whatever address the caller claims) could try
|
||||
// one account's password without end. This bucket follows the account
|
||||
// wherever the guesses come from.
|
||||
//
|
||||
// Keyed on the account when the identifier resolves and on the identifier's
|
||||
// hash when it does not, with the same ceiling, so a locked answer says
|
||||
// nothing about whether the account exists. Charged before the Argon2id
|
||||
// verification (a locked account costs no hashing) and refunded on a
|
||||
// successful sign-in, so it counts failures: the owner signing in does not
|
||||
// use it up. While it is spent, a passkey or single sign-on still works.
|
||||
const accountBucket = user
|
||||
? `${LOGIN_ACCOUNT_BUCKET}:u:${user.id}`
|
||||
: `${LOGIN_ACCOUNT_BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
|
||||
const accountRl = await checkRateLimit(
|
||||
accountBucket,
|
||||
LOGIN_ACCOUNT_LIMIT,
|
||||
LOGIN_ACCOUNT_WINDOW_MS,
|
||||
);
|
||||
if (!accountRl.allowed) {
|
||||
// Under an SSO-only policy the one password sign-in allowed is the one that
|
||||
// confirms the account a pending single sign-on link names. Any other
|
||||
// account is refused BEFORE its password is checked: refusing after the
|
||||
// check answered "wrong password" and "right password, wrong account"
|
||||
// differently, which made this exception an oracle for every other
|
||||
// account's password.
|
||||
if (pendingLink && pendingLink.userId !== user?.id) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// The per-account half of the throttle (`@/lib/auth/login-throttle`): a
|
||||
// place this account has signed in from is neither counted nor held back,
|
||||
// anywhere else waits longer after each failure past the first few, up to
|
||||
// fifteen minutes. Decided before the Argon2id verification, so a waiting
|
||||
// attempt costs no hashing.
|
||||
const attempt = await beginAccountLoginAttempt({
|
||||
user,
|
||||
identifier,
|
||||
request,
|
||||
ip: rl.ip,
|
||||
});
|
||||
annotate({ meta: { login_known_source: attempt.source ?? "none" } });
|
||||
if (attempt.waiting) {
|
||||
await auditLog("auth.login.account_throttled", {
|
||||
userId: user?.id,
|
||||
ipAddress: ip,
|
||||
@@ -137,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: null,
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(accountRl) },
|
||||
{ status: 429, headers: rateLimitHeaders(attempt.waiting) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
await attempt.failed();
|
||||
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
|
||||
// the audit row — `reason` already tells the operator what
|
||||
// happened, and PII must not land in operator artefacts. The
|
||||
@@ -168,6 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
await attempt.failed();
|
||||
await auditLog("auth.login.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
@@ -176,14 +169,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
if (pendingLink && pendingLink.userId !== user.id) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// The password was right: this attempt was not a failed guess.
|
||||
await refundRateLimit(accountBucket);
|
||||
await attempt.succeeded();
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The per-account password throttle, against an in-memory rate-limit table.
|
||||
*
|
||||
* The case it exists for: somebody who is not the owner types wrong passwords
|
||||
* for the account from two addresses. That used to lock a password-only
|
||||
* account (the phone app's password sign-in with it) for as long as they kept
|
||||
* it up. Now the owner's own device or network is not held back at all, and
|
||||
* anywhere else waits a bounded, growing time.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const table = new Map<string, { count: number; resetAt: number }>();
|
||||
let now = Date.parse("2026-09-26T12:00:00Z");
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(
|
||||
async (key: string, limit: number, windowMs: number) => {
|
||||
const row = table.get(key);
|
||||
const fresh = !row || row.resetAt < now;
|
||||
const next = fresh
|
||||
? { count: 1, resetAt: now + windowMs }
|
||||
: { count: row.count + 1, resetAt: row.resetAt };
|
||||
table.set(key, next);
|
||||
return {
|
||||
allowed: next.count <= limit,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - next.count),
|
||||
resetAt: next.resetAt,
|
||||
};
|
||||
},
|
||||
),
|
||||
refundRateLimit: vi.fn(async (key: string) => {
|
||||
const row = table.get(key);
|
||||
if (row && row.resetAt >= now) row.count = Math.max(0, row.count - 1);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
rateLimit: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { key: string } }) => {
|
||||
const row = table.get(where.key);
|
||||
return row ? { resetAt: new Date(row.resetAt) } : null;
|
||||
}),
|
||||
upsert: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
create,
|
||||
}: {
|
||||
where: { key: string };
|
||||
create: { count: number; resetAt: Date };
|
||||
}) => {
|
||||
table.set(where.key, {
|
||||
count: create.count,
|
||||
resetAt: create.resetAt.getTime(),
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
trustedDevice: { findUnique: vi.fn(async () => null) },
|
||||
refreshToken: { findFirst: vi.fn(async () => null) },
|
||||
session: { findFirst: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
|
||||
let deviceCookie: string | undefined;
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => (deviceCookie ? { value: deviceCookie } : undefined),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (v: string) => `h(${v})` }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
beginAccountLoginAttempt,
|
||||
BASE_WAIT_MS,
|
||||
FREE_FAILURES,
|
||||
MAX_WAIT_MS,
|
||||
waitAfter,
|
||||
} from "../login-throttle";
|
||||
|
||||
const OWNER = { id: "owner-1" };
|
||||
|
||||
function req(headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** One password attempt; returns "waiting" or whether it was checked. */
|
||||
async function guess(
|
||||
ip: string,
|
||||
opts: { right?: boolean; headers?: Record<string, string> } = {},
|
||||
): Promise<"waiting" | "checked"> {
|
||||
const attempt = await beginAccountLoginAttempt({
|
||||
user: OWNER,
|
||||
identifier: "owner",
|
||||
request: req(opts.headers),
|
||||
ip,
|
||||
});
|
||||
if (attempt.waiting) return "waiting";
|
||||
if (opts.right) await attempt.succeeded();
|
||||
else await attempt.failed();
|
||||
return "checked";
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
table.clear();
|
||||
now = Date.parse("2026-09-26T12:00:00Z");
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
deviceCookie = undefined;
|
||||
vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.session.findFirst).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("waitAfter", () => {
|
||||
it("is free for the first few failures, then doubles up to the cap", () => {
|
||||
expect(waitAfter(FREE_FAILURES - 1)).toBe(0);
|
||||
expect(waitAfter(FREE_FAILURES)).toBe(BASE_WAIT_MS);
|
||||
expect(waitAfter(FREE_FAILURES + 1)).toBe(2 * BASE_WAIT_MS);
|
||||
expect(waitAfter(FREE_FAILURES + 100)).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("somebody guessing from two addresses", () => {
|
||||
async function tenGuesses() {
|
||||
const answers: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
answers.push(await guess(i % 2 ? "198.51.100.1" : "198.51.100.2"));
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
it("is slowed down, not given a lock that lasts", async () => {
|
||||
const answers = await tenGuesses();
|
||||
// The first five are checked, then the wait holds the rest back.
|
||||
expect(answers.slice(0, FREE_FAILURES)).toEqual(
|
||||
Array(FREE_FAILURES).fill("checked"),
|
||||
);
|
||||
expect(answers.slice(FREE_FAILURES)).toContain("waiting");
|
||||
});
|
||||
|
||||
it("the owner's phone, known by the device id it signed in with, still gets through", async () => {
|
||||
await tenGuesses();
|
||||
vi.mocked(prisma.refreshToken.findFirst).mockImplementation((async (args: {
|
||||
where: { deviceId?: string };
|
||||
}) =>
|
||||
args.where.deviceId === "owner-phone" ? { id: "rt-1" } : null) as never);
|
||||
expect(
|
||||
await guess("203.0.113.50", {
|
||||
right: true,
|
||||
headers: { "x-device-id": "owner-phone" },
|
||||
}),
|
||||
).toBe("checked");
|
||||
});
|
||||
|
||||
it("the owner's remembered browser still gets through", async () => {
|
||||
await tenGuesses();
|
||||
deviceCookie = "device-token";
|
||||
vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({
|
||||
userId: "owner-1",
|
||||
expiresAt: new Date(now + 1e9),
|
||||
} as never);
|
||||
expect(await guess("203.0.113.51", { right: true })).toBe("checked");
|
||||
});
|
||||
|
||||
it("the owner's home network, seen on a recent session, still gets through", async () => {
|
||||
await tenGuesses();
|
||||
vi.mocked(prisma.session.findFirst).mockImplementation((async (args: {
|
||||
where: { ipAddress?: string };
|
||||
}) =>
|
||||
args.where.ipAddress === "203.0.113.9" ? { id: "s-1" } : null) as never);
|
||||
expect(await guess("203.0.113.9", { right: true })).toBe("checked");
|
||||
});
|
||||
|
||||
it("a place the account has signed in from never counts toward the wait", async () => {
|
||||
vi.mocked(prisma.session.findFirst).mockResolvedValue({
|
||||
id: "s-1",
|
||||
} as never);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(await guess("203.0.113.9")).toBe("checked");
|
||||
}
|
||||
vi.mocked(prisma.session.findFirst).mockResolvedValue(null);
|
||||
expect(await guess("198.51.100.1")).toBe("checked");
|
||||
});
|
||||
|
||||
it("an unknown place waits at most fifteen minutes, however long the guessing went on", async () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await guess("198.51.100.1");
|
||||
now += MAX_WAIT_MS; // the guesser comes back as soon as allowed
|
||||
}
|
||||
await guess("198.51.100.1"); // one more failure, the longest wait
|
||||
now += MAX_WAIT_MS + 1;
|
||||
expect(await guess("203.0.113.77", { right: true })).toBe("checked");
|
||||
});
|
||||
|
||||
it("a right password gives its attempt back", async () => {
|
||||
await guess("198.51.100.1");
|
||||
await guess("198.51.100.1", { right: true });
|
||||
expect(table.get("auth:login:account:u:owner-1")?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an identifier that names no account", () => {
|
||||
it("runs the same arithmetic on its hash", async () => {
|
||||
for (let i = 0; i < FREE_FAILURES + 1; i++) {
|
||||
const attempt = await beginAccountLoginAttempt({
|
||||
user: null,
|
||||
identifier: "Nobody",
|
||||
request: req(),
|
||||
ip: "198.51.100.1",
|
||||
});
|
||||
await attempt.failed();
|
||||
}
|
||||
expect(table.has("auth:login:account:i:h(nobody):wait")).toBe(true);
|
||||
expect(prisma.session.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The per-account half of the password sign-in throttle.
|
||||
*
|
||||
* The per-IP bucket on the login route is only as good as the address it is
|
||||
* keyed on, so a second limit follows the account wherever the guesses come
|
||||
* from. It used to be a hard ceiling: ten failures in fifteen minutes and the
|
||||
* account refused every password, charged before the password was checked.
|
||||
* That made the owner's own sign-in a thing anybody could switch off. Ten
|
||||
* wrong guesses from two addresses locked a password-only account, the phone
|
||||
* app's password sign-in with it, and repeating them every fifteen minutes
|
||||
* kept it locked for as long as the person bothered.
|
||||
*
|
||||
* Two changes answer that.
|
||||
*
|
||||
* A place the account has signed in from before is not counted and not
|
||||
* blocked. "Before" means a remembered browser (the trusted-device cookie,
|
||||
* checked without being spent), a phone that presents the device id a login
|
||||
* of this account was issued to, or an address a session or device login of
|
||||
* this account was created from in the last thirty days. The guesser is by
|
||||
* definition somewhere the account has not signed in from; the owner mostly
|
||||
* is not. The per-IP limit still applies to every caller.
|
||||
*
|
||||
* Everywhere else gets a growing wait instead of a wall. Five failures a day
|
||||
* cost nothing; after that each failure makes the next attempt wait twice as
|
||||
* long as the last, starting at thirty seconds and never more than fifteen
|
||||
* minutes. A guesser gets a handful of tries an hour, which is as hopeless as
|
||||
* the old ceiling, and the owner on a new network waits minutes at worst,
|
||||
* never indefinitely.
|
||||
*
|
||||
* An identifier that names no account runs the same arithmetic on its hash,
|
||||
* so a wait says nothing about whether the account exists.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { TRUSTED_DEVICE_COOKIE } from "@/lib/auth/trusted-device";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import type { RateLimitSnapshot } from "@/lib/rate-limit-context";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BUCKET = "auth:login:account";
|
||||
/** Failures a day an unknown place may make before it starts waiting. */
|
||||
export const FREE_FAILURES = 5;
|
||||
const FAILURE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/** The first wait, doubled by every further failure. */
|
||||
export const BASE_WAIT_MS = 30 * 1000;
|
||||
/** No wait is ever longer than this. */
|
||||
export const MAX_WAIT_MS = 15 * 60 * 1000;
|
||||
/** How far back a sign-in from an address still makes it a known place. */
|
||||
const KNOWN_ADDRESS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* The failure counter is read, not enforced, so its ceiling only has to be
|
||||
* out of reach; `limit - remaining` is the count.
|
||||
*/
|
||||
const COUNTER_CEILING = 1_000_000;
|
||||
|
||||
export type KnownSource = "trusted_device" | "device_id" | "address";
|
||||
|
||||
/**
|
||||
* Has this account signed in from where this request comes from? Read-only:
|
||||
* nothing here spends the trusted device or touches a row.
|
||||
*/
|
||||
export async function knownSignInSource(
|
||||
userId: string,
|
||||
request: Request,
|
||||
ip: string | null,
|
||||
): Promise<KnownSource | null> {
|
||||
const now = new Date();
|
||||
|
||||
let deviceToken: string | undefined;
|
||||
try {
|
||||
deviceToken = (await cookies()).get(TRUSTED_DEVICE_COOKIE)?.value;
|
||||
} catch {
|
||||
deviceToken = undefined;
|
||||
}
|
||||
if (deviceToken) {
|
||||
const row = await prisma.trustedDevice.findUnique({
|
||||
where: { tokenHash: hashToken(deviceToken) },
|
||||
select: { userId: true, expiresAt: true },
|
||||
});
|
||||
if (row && row.userId === userId && row.expiresAt > now) {
|
||||
return "trusted_device";
|
||||
}
|
||||
}
|
||||
|
||||
const deviceId = request.headers.get("x-device-id")?.trim();
|
||||
if (deviceId && deviceId.length <= 200) {
|
||||
const login = await prisma.refreshToken.findFirst({
|
||||
where: { userId, deviceId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (login) return "device_id";
|
||||
}
|
||||
|
||||
if (ip) {
|
||||
const since = new Date(now.getTime() - KNOWN_ADDRESS_WINDOW_MS);
|
||||
const [session, login] = await Promise.all([
|
||||
prisma.session.findFirst({
|
||||
where: { userId, ipAddress: ip, createdAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.refreshToken.findFirst({
|
||||
where: { userId, ipAddress: ip, createdAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
if (session || login) return "address";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How long the next attempt waits after this many failures in a day. */
|
||||
export function waitAfter(failures: number): number {
|
||||
if (failures < FREE_FAILURES) return 0;
|
||||
const doublings = Math.min(failures - FREE_FAILURES, 20);
|
||||
return Math.min(BASE_WAIT_MS * 2 ** doublings, MAX_WAIT_MS);
|
||||
}
|
||||
|
||||
export interface AccountLoginAttempt {
|
||||
/** Set when the attempt must not proceed: the wait still running. */
|
||||
waiting: RateLimitSnapshot | null;
|
||||
/** Where the attempt comes from, when the account has signed in there. */
|
||||
source: KnownSource | null;
|
||||
/** The password was wrong. */
|
||||
failed(): Promise<void>;
|
||||
/** The password was right: this was not a guess. */
|
||||
succeeded(): Promise<void>;
|
||||
}
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
/**
|
||||
* Decide whether a password attempt against this account (or this unknown
|
||||
* identifier) may be checked at all, before the Argon2id verification runs.
|
||||
*/
|
||||
export async function beginAccountLoginAttempt(args: {
|
||||
user: Pick<User, "id"> | null;
|
||||
identifier: string;
|
||||
request: Request;
|
||||
ip: string | null;
|
||||
}): Promise<AccountLoginAttempt> {
|
||||
const { user, identifier, request, ip } = args;
|
||||
|
||||
if (user) {
|
||||
const source = await knownSignInSource(user.id, request, ip);
|
||||
if (source) {
|
||||
return { waiting: null, source, failed: noop, succeeded: noop };
|
||||
}
|
||||
}
|
||||
|
||||
const key = user
|
||||
? `${BUCKET}:u:${user.id}`
|
||||
: `${BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
|
||||
const waitKey = `${key}:wait`;
|
||||
|
||||
const wait = await prisma.rateLimit.findUnique({
|
||||
where: { key: waitKey },
|
||||
select: { resetAt: true },
|
||||
});
|
||||
if (wait && wait.resetAt.getTime() > Date.now()) {
|
||||
return {
|
||||
waiting: {
|
||||
limit: FREE_FAILURES,
|
||||
remaining: 0,
|
||||
resetAt: wait.resetAt.getTime(),
|
||||
},
|
||||
source: null,
|
||||
failed: noop,
|
||||
succeeded: noop,
|
||||
};
|
||||
}
|
||||
|
||||
// Charged before the verification so a failure is counted even if the
|
||||
// request dies after it; given back when the password turns out right.
|
||||
const counted = await checkRateLimit(key, COUNTER_CEILING, FAILURE_WINDOW_MS);
|
||||
const failures = counted.limit - counted.remaining;
|
||||
|
||||
return {
|
||||
waiting: null,
|
||||
source: null,
|
||||
failed: async () => {
|
||||
const ms = waitAfter(failures);
|
||||
if (ms === 0) return;
|
||||
const resetAt = new Date(Date.now() + ms);
|
||||
await prisma.rateLimit.upsert({
|
||||
where: { key: waitKey },
|
||||
create: { key: waitKey, count: 1, resetAt },
|
||||
update: { count: 1, resetAt },
|
||||
});
|
||||
},
|
||||
succeeded: () => refundRateLimit(key),
|
||||
};
|
||||
}
|
||||
@@ -671,7 +671,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Email-or-username login (password)",
|
||||
description:
|
||||
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.\n\n" +
|
||||
"v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged.",
|
||||
"v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged.\n\n" +
|
||||
"Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a place the account has not signed in from before make the next attempt wait: five failures a day are free, then each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the `X-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. Both answer 429 with `Retry-After`. An identifier that names no account is limited the same way, so a 429 says nothing about whether it exists.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: loginPasswordSchema } },
|
||||
@@ -691,7 +692,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account.",
|
||||
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account. A sign-in to any other account on that browser is refused before its password is checked, so the answer is the same whether the password was right or wrong.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
|
||||
@@ -178,6 +178,17 @@ describe("under an SSO-only policy", () => {
|
||||
expect((await linkOf("acct-a")).oidcSub).toBe("idp-sub-1");
|
||||
});
|
||||
|
||||
it("answers the same for another account's right and wrong password", async () => {
|
||||
// Refused before the password is checked, so the exception cannot be
|
||||
// used to test passwords of accounts the link does not name.
|
||||
pendingLinkFor("acct-a");
|
||||
const right = await passwordLogin("acct-b");
|
||||
const wrong = await passwordLogin("acct-b", "not the password");
|
||||
expect(right.status).toBe(403);
|
||||
expect(wrong.status).toBe(403);
|
||||
expect(await wrong.json()).toEqual(await right.json());
|
||||
});
|
||||
|
||||
it("keeps it closed for any other account", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-b");
|
||||
|
||||
Reference in new issue
Block a user