From 03030639d74487a614ca8cbc77ea263d67fdd1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 17 Apr 2026 08:50:25 +0200 Subject: [PATCH] security: constant-time authorize + uniform audit summaries (#40) Prevent user-enumeration via login-response timing and audit-log content. All failing branches now run argon2.verify against a precomputed dummy hash (discarding the result), and emit a single "Login failed" audit summary. Detailed reason stays in the server-only pino logger. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/server/auth.test.ts | 112 +++++++++++++++++++++++++++++-- apps/web/src/server/auth.ts | 26 +++++-- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/apps/web/src/server/auth.test.ts b/apps/web/src/server/auth.test.ts index a80d7db..c14481b 100644 --- a/apps/web/src/server/auth.test.ts +++ b/apps/web/src/server/auth.test.ts @@ -10,7 +10,7 @@ * runtime and is covered by E2E tests instead. */ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; // ── next-auth imports next/server without .js extension which fails in vitest // node env. Mock the whole module so the error classes can be imported. @@ -26,16 +26,31 @@ vi.mock("next-auth", () => { // ── All other side-effectful imports auth.ts pulls in ─────────────────────── vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() })); -vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() })); -vi.mock("@capakraken/db", () => ({ - prisma: { user: {}, systemSettings: {}, activeSession: {} }, + +// Capture the config passed to Credentials() so we can call authorize(). +const credentialsCalls: Array<{ authorize: (...args: unknown[]) => unknown }> = []; +vi.mock("next-auth/providers/credentials", () => ({ + default: vi.fn((cfg: { authorize: (...args: unknown[]) => unknown }) => { + credentialsCalls.push(cfg); + return cfg; + }), +})); + +const prismaMock = { + user: { findUnique: vi.fn(), update: vi.fn() }, + systemSettings: { findUnique: vi.fn() }, + activeSession: { create: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn(), delete: vi.fn() }, +}; +vi.mock("@capakraken/db", () => ({ prisma: prismaMock })); +vi.mock("@capakraken/api/middleware/rate-limit", () => ({ + authRateLimiter: vi.fn().mockResolvedValue({ allowed: true }), })); -vi.mock("@capakraken/api/middleware/rate-limit", () => ({ authRateLimiter: vi.fn() })); vi.mock("@capakraken/api/lib/audit", () => ({ createAuditEntry: vi.fn() })); vi.mock("@capakraken/api/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); -vi.mock("@node-rs/argon2", () => ({ verify: vi.fn() })); +const argonVerifyMock = vi.fn(); +vi.mock("@node-rs/argon2", () => ({ verify: argonVerifyMock })); // ── Import the exported error classes after mocks are in place ─────────────── const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js"); @@ -66,3 +81,88 @@ describe("MFA CredentialsSignin error classes — code property", () => { expect(new InvalidTotpError().constructor.name).toBe("InvalidTotpError"); }); }); + +describe("authorize() — login timing / enumeration defence", () => { + const authorize = credentialsCalls[0]?.authorize; + + if (!authorize) { + it.skip("authorize was not captured", () => {}); + return; + } + + beforeEach(() => { + argonVerifyMock.mockReset(); + prismaMock.user.findUnique.mockReset(); + prismaMock.user.update.mockReset(); + prismaMock.systemSettings.findUnique.mockReset(); + }); + + it("runs argon2.verify against a dummy hash when the user is not found", async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + argonVerifyMock.mockResolvedValue(false); + + const result = await authorize( + { email: "nobody@example.com", password: "s3cret-password" }, + undefined, + ); + + expect(result).toBeNull(); + expect(argonVerifyMock).toHaveBeenCalledTimes(1); + const [hashArg, passwordArg] = argonVerifyMock.mock.calls[0]!; + expect(typeof hashArg).toBe("string"); + expect(hashArg).toMatch(/^\$argon2id\$/); + expect(passwordArg).toBe("s3cret-password"); + }); + + it("runs argon2.verify against a dummy hash when the account is deactivated", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: "u1", + email: "x@example.com", + isActive: false, + passwordHash: "$argon2id$real$hash", + }); + argonVerifyMock.mockResolvedValue(false); + + const result = await authorize({ email: "x@example.com", password: "wrong" }, undefined); + + expect(result).toBeNull(); + expect(argonVerifyMock).toHaveBeenCalledTimes(1); + expect(argonVerifyMock.mock.calls[0]![0]).toMatch(/^\$argon2id\$/); + }); + + it("records a uniform 'Login failed' audit summary for every failure branch", async () => { + const { createAuditEntry } = await import("@capakraken/api/lib/audit"); + const auditMock = createAuditEntry as unknown as ReturnType; + auditMock.mockClear(); + + // Branch 1: user not found + prismaMock.user.findUnique.mockResolvedValueOnce(null); + argonVerifyMock.mockResolvedValueOnce(false); + await authorize({ email: "a@example.com", password: "p" }, undefined); + + // Branch 2: deactivated account + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: "u1", + email: "b@example.com", + isActive: false, + passwordHash: "$argon2id$h", + }); + argonVerifyMock.mockResolvedValueOnce(false); + await authorize({ email: "b@example.com", password: "p" }, undefined); + + // Branch 3: wrong password + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: "u2", + email: "c@example.com", + isActive: true, + passwordHash: "$argon2id$h", + }); + argonVerifyMock.mockResolvedValueOnce(false); + await authorize({ email: "c@example.com", password: "p" }, undefined); + + const summaries = auditMock.mock.calls.map( + (call: unknown[]) => (call[0] as { summary: string }).summary, + ); + expect(summaries).toEqual(["Login failed", "Login failed", "Login failed"]); + }); +}); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 9fbe74c..7cd26f6 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -12,6 +12,15 @@ import { authConfig } from "./auth.config.js"; assertSecureRuntimeEnv(); +// Precomputed argon2id hash of a random string we do not retain. Used to run a +// dummy verify() when the user does not exist (or has no password hash) so the +// code path takes the same wall-clock time as a real failed-login for a +// known user. Without this, an attacker can enumerate valid accounts by +// measuring how fast "email not found" returns vs. "password wrong" +// (EAPPS 3.2.7.05 / OWASP ASVS 2.2.1). +const DUMMY_ARGON2_HASH = + "$argon2id$v=19$m=65536,t=3,p=4$dFRrYlpCaTMzd1lHeFMwTw$wZcMWHRxxOy2trvRfOjjKzYP/VQ2k+D01FA54zUlfUw"; + // Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded // to the client via SignInResponse.code — plain Error throws become // CallbackRouteError and the message is never visible to the client. @@ -88,7 +97,16 @@ const config = { } const user = await prisma.user.findUnique({ where: { email } }); + + // Always run argon2.verify — even when the user doesn't exist or is + // deactivated — so all failing branches incur the same CPU cost. The + // result from the dummy path is discarded; only the shape of the + // audit log / return value changes. Summaries are kept uniform + // ("Login failed") so audit-log contents cannot be used to + // enumerate accounts either; the reason stays in the server-only + // logger.warn. if (!user?.passwordHash) { + await verify(DUMMY_ARGON2_HASH, password).catch(() => false); logger.warn({ email, reason: "user_not_found" }, "Failed login attempt"); void createAuditEntry({ db: prisma, @@ -96,13 +114,14 @@ const config = { entityId: email.toLowerCase(), entityName: email, action: "CREATE", - summary: "Login failed — user not found", + summary: "Login failed", source: "ui", }); return null; } if (!user.isActive) { + await verify(DUMMY_ARGON2_HASH, password).catch(() => false); logger.warn( { email, userId: user.id, reason: "account_deactivated" }, "Login blocked — account deactivated", @@ -114,7 +133,7 @@ const config = { entityName: user.email, action: "CREATE", userId: user.id, - summary: "Login blocked — account deactivated", + summary: "Login failed", source: "ui", }); return null; @@ -123,7 +142,6 @@ const config = { const isValid = await verify(user.passwordHash, password); if (!isValid) { logger.warn({ email, reason: "invalid_password" }, "Failed login attempt"); - // Audit failed login (bad password) void createAuditEntry({ db: prisma, entityType: "Auth", @@ -131,7 +149,7 @@ const config = { entityName: user.email, action: "CREATE", userId: user.id, - summary: "Login failed — invalid password", + summary: "Login failed", source: "ui", }); return null;