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:
Marc-André Bombeck committed 2026-09-26 22:42:40 +02:00
1 parent 1900792756
commit 995bd2385a
11 files changed
+527 -57

No files matched your search

+12 -1
View File
@@ -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:
+14 -10
View File
@@ -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() },
+31 -44
View File
@@ -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();
});
});
+194
View File
@@ -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),
};
}
+3 -2
View File
@@ -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");