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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user