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.
|
* 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
|
// ── 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.
|
// 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 ───────────────────────
|
// ── All other side-effectful imports auth.ts pulls in ───────────────────────
|
||||||
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));
|
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));
|
||||||
vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() }));
|
|
||||||
vi.mock("@capakraken/db", () => ({
|
// Capture the config passed to Credentials() so we can call authorize().
|
||||||
prisma: { user: {}, systemSettings: {}, activeSession: {} },
|
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/audit", () => ({ createAuditEntry: vi.fn() }));
|
||||||
vi.mock("@capakraken/api/lib/logger", () => ({
|
vi.mock("@capakraken/api/lib/logger", () => ({
|
||||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
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 ───────────────
|
// ── Import the exported error classes after mocks are in place ───────────────
|
||||||
const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js");
|
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");
|
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();
|
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
|
// Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded
|
||||||
// to the client via SignInResponse.code — plain Error throws become
|
// to the client via SignInResponse.code — plain Error throws become
|
||||||
// CallbackRouteError and the message is never visible to the client.
|
// CallbackRouteError and the message is never visible to the client.
|
||||||
@@ -88,7 +97,16 @@ const config = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
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) {
|
if (!user?.passwordHash) {
|
||||||
|
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
|
||||||
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
|
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: prisma,
|
db: prisma,
|
||||||
@@ -96,13 +114,14 @@ const config = {
|
|||||||
entityId: email.toLowerCase(),
|
entityId: email.toLowerCase(),
|
||||||
entityName: email,
|
entityName: email,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
summary: "Login failed — user not found",
|
summary: "Login failed",
|
||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
|
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ email, userId: user.id, reason: "account_deactivated" },
|
{ email, userId: user.id, reason: "account_deactivated" },
|
||||||
"Login blocked — account deactivated",
|
"Login blocked — account deactivated",
|
||||||
@@ -114,7 +133,7 @@ const config = {
|
|||||||
entityName: user.email,
|
entityName: user.email,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
summary: "Login blocked — account deactivated",
|
summary: "Login failed",
|
||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
@@ -123,7 +142,6 @@ const config = {
|
|||||||
const isValid = await verify(user.passwordHash, password);
|
const isValid = await verify(user.passwordHash, password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
|
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
|
||||||
// Audit failed login (bad password)
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: prisma,
|
db: prisma,
|
||||||
entityType: "Auth",
|
entityType: "Auth",
|
||||||
@@ -131,7 +149,7 @@ const config = {
|
|||||||
entityName: user.email,
|
entityName: user.email,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
summary: "Login failed — invalid password",
|
summary: "Login failed",
|
||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user