diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fbe1426cd..9aeb0e65d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index 3210a5c44..1b9a644e1 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -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 () => { diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts index 977ab9e51..6490b756a 100644 --- a/src/app/api/auth/login/__tests__/constant-work.test.ts +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -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() }, diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 73e5d82d9..231483378 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -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() }, diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index fb2df339b..6a8d06c6a 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -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() }, diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts index c6405653a..95b7a3492 100644 --- a/src/app/api/auth/login/__tests__/trusted-device.test.ts +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -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() }, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 2c07466cb..75dc974d6 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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"); diff --git a/src/lib/auth/__tests__/login-throttle.test.ts b/src/lib/auth/__tests__/login-throttle.test.ts new file mode 100644 index 000000000..b478cdd09 --- /dev/null +++ b/src/lib/auth/__tests__/login-throttle.test.ts @@ -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(); +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 = {}): 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 } = {}, +): 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(); + }); +}); diff --git a/src/lib/auth/login-throttle.ts b/src/lib/auth/login-throttle.ts new file mode 100644 index 000000000..1420e95ef --- /dev/null +++ b/src/lib/auth/login-throttle.ts @@ -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 { + 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; + /** The password was right: this was not a guess. */ + succeeded(): Promise; +} + +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 | null; + identifier: string; + request: Request; + ip: string | null; +}): Promise { + 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), + }; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 961b25e76..fc4baf4df 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -671,7 +671,8 @@ export const authPaths: NonNullable = { 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 = { }, "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, diff --git a/tests/integration/oidc-pending-link.test.ts b/tests/integration/oidc-pending-link.test.ts index 10a17d90d..19b97c781 100644 --- a/tests/integration/oidc-pending-link.test.ts +++ b/tests/integration/oidc-pending-link.test.ts @@ -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");