修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)

这个提交包含在:
2026-07-17 14:24:58 +08:00
父节点 bbb5c89825
当前提交 6ddedf1e0e
共修改 31 个文件,包含 3801 行新增和 419 行删除
@@ -0,0 +1,173 @@
const crypto = require("node:crypto");
const fp = require("fastify-plugin");
const { buildApp } = require("../../src/app");
const { getConfig } = require("../../src/config");
const {
generateSixDigitCode,
hashAuthCode,
timingSafeEqualHex,
} = require("../../src/utils/crypto");
const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
function stubBaseEnv(nodeEnv = "test") {
vi.stubEnv("NODE_ENV", nodeEnv);
vi.stubEnv("JWT_SECRET", "test-jwt-secret");
vi.stubEnv("DATABASE_URL", "");
vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : "");
vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined);
vi.stubEnv("JOBS_ENABLED", "false");
vi.stubEnv("METRICS_ENABLED", "false");
vi.stubEnv("SERVE_CLIENT_DIST", "false");
vi.stubEnv("OPENAPI_DOCS_ENABLED", "false");
}
function createPrismaPlugin() {
return fp(async (app) => {
app.decorate("prisma", {
$queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]),
employee: {
findUnique: vi.fn(),
},
});
}, { name: "test-prisma" });
}
async function buildIpProbeApp() {
const app = await buildApp({
prismaPlugin: createPrismaPlugin(),
startJobs: vi.fn(async () => ({ driver: "disabled" })),
});
app.get("/__test/ip", async (request) => ({
ip: request.ip,
ips: request.ips,
}));
return app;
}
describe("auth security configuration", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("uses crypto.randomInt for padded six-digit codes", () => {
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42);
expect(generateSixDigitCode()).toBe("000042");
expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000);
});
it("separates auth code hashes by purpose and compares them safely", () => {
const common = {
employeeId: "11111111-1111-4111-8111-111111111111",
code: "123456",
pepper: STRONG_PEPPER,
};
const emailHash = hashAuthCode({ ...common, purpose: "email_login" });
const resetHash = hashAuthCode({ ...common, purpose: "password_reset" });
expect(emailHash).not.toBe(resetHash);
expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true);
expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false);
expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false);
});
it("fails closed when production has no auth code pepper", () => {
stubBaseEnv("production");
vi.stubEnv("AUTH_CODE_PEPPER", "");
expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER");
});
it.each([
"<managed-auth-code-pepper>",
"short-secret",
])("rejects a weak production auth code pepper", (pepper) => {
stubBaseEnv("production");
vi.stubEnv("AUTH_CODE_PEPPER", pepper);
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret");
});
it("rejects a production auth code pepper reused as the JWT secret", () => {
stubBaseEnv("production");
vi.stubEnv("JWT_SECRET", STRONG_PEPPER);
vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER);
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
});
it("forbids development password reset code logging in production", () => {
stubBaseEnv("production");
vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true");
expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
});
it.each([
[undefined, "Missing required env: TRUST_PROXY_HOPS"],
["", "Invalid TRUST_PROXY_HOPS"],
["0", "Invalid TRUST_PROXY_HOPS"],
["2", "Invalid TRUST_PROXY_HOPS"],
["true", "Invalid TRUST_PROXY_HOPS"],
])("requires exactly one trusted proxy hop in production", (value, error) => {
stubBaseEnv("production");
vi.stubEnv("TRUST_PROXY_HOPS", value);
expect(() => getConfig()).toThrow(error);
});
it.each([
["PASSWORD_RESET_MAX_ATTEMPTS", "0"],
["PASSWORD_RESET_LOCK_SECONDS", "-1"],
["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"],
["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"],
])("rejects unsafe numeric auth configuration", (name, value) => {
stubBaseEnv();
vi.stubEnv(name, value);
expect(() => getConfig()).toThrow(`Invalid ${name}`);
});
it("ignores a forged X-Forwarded-For header by default", async () => {
stubBaseEnv();
const app = await buildIpProbeApp();
try {
const response = await app.inject({
method: "GET",
url: "/__test/ip",
headers: { "x-forwarded-for": "10.10.10.10" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ip: "127.0.0.1" });
} finally {
await app.close();
}
}, 20_000);
it("trusts only the nearest address supplied by one controlled proxy", async () => {
stubBaseEnv();
vi.stubEnv("TRUST_PROXY_HOPS", "1");
const app = await buildIpProbeApp();
try {
const response = await app.inject({
method: "GET",
url: "/__test/ip",
headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
ip: "192.168.1.20",
ips: ["127.0.0.1", "192.168.1.20"],
});
} finally {
await app.close();
}
}, 20_000);
});
+701 -34
查看文件
@@ -4,21 +4,33 @@ const fastifyCookie = require("@fastify/cookie");
const authRoutes = require("../../../src/routes/auth");
const { AppError } = require("../../../src/utils/AppError");
const { hashPassword, sha256Hex } = require("../../../src/utils/crypto");
const { hashAuthCode, hashPassword, verifyPassword } = require("../../../src/utils/crypto");
const { signPayload } = require("../../../src/utils/signedUrl");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const JWT_SECRET = "test-auth-secret";
const AUTH_CODE_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
function hashEmailLoginCode(code) {
return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex");
return hashAuthCode({
employeeId: EMPLOYEE_ID,
code,
purpose: "email_login",
pepper: AUTH_CODE_PEPPER,
});
}
async function buildApp(passwordHash) {
const app = Fastify();
await app.register(fastifyCookie);
function hashPasswordResetCode(code) {
return hashAuthCode({
employeeId: EMPLOYEE_ID,
code,
purpose: "password_reset",
pepper: AUTH_CODE_PEPPER,
});
}
const employeeFindFirst = vi.fn().mockResolvedValue({
function buildEmployee(passwordHash) {
return {
id: EMPLOYEE_ID,
status: "active",
password_hash: passwordHash,
@@ -27,9 +39,33 @@ async function buildApp(passwordHash) {
login_failed_count: 0,
login_failed_first_at: null,
login_locked_until: null,
});
email_login_code_failed_count: 0,
email_login_code_failed_first_at: null,
email_login_code_locked_until: null,
email_login_code_last_requested_at: null,
password_reset_failed_count: 0,
password_reset_failed_first_at: null,
password_reset_locked_until: null,
password_reset_request_count: 0,
password_reset_request_first_at: null,
password_reset_last_requested_at: null,
};
}
async function buildApp(passwordHash) {
const app = Fastify();
await app.register(fastifyCookie);
const employee = buildEmployee(passwordHash);
const employeeFindFirst = vi.fn().mockResolvedValue(employee);
const employeeFindUnique = vi.fn().mockResolvedValue(employee);
const employeeFindMany = vi.fn().mockResolvedValue([]);
const employeeUpdate = vi.fn().mockResolvedValue({});
const employeeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const queryRaw = vi.fn().mockResolvedValue([employee]);
const authSessionFindFirst = vi.fn().mockResolvedValue(null);
const txAuthSessionFindFirst = vi.fn().mockResolvedValue(null);
const authSessionFindUnique = vi.fn().mockResolvedValue(null);
const authSessionFindMany = vi.fn().mockResolvedValue([]);
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
@@ -37,25 +73,36 @@ async function buildApp(passwordHash) {
const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 });
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" });
const passwordResetCodeFindFirst = vi.fn().mockResolvedValue(null);
const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const wecomFindFirst = vi.fn().mockResolvedValue(null);
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
const tx = {
$queryRaw: queryRaw,
employee: {
update: employeeUpdate,
updateMany: employeeUpdateMany,
},
authSession: {
findFirst: txAuthSessionFindFirst,
findUnique: authSessionFindUnique,
findMany: authSessionFindMany,
updateMany: authSessionUpdateMany,
create: authSessionCreate,
},
emailLoginCode: {
update: emailLoginCodeUpdate,
create: emailLoginCodeCreate,
findFirst: emailLoginCodeFindFirst,
updateMany: emailLoginCodeUpdateMany,
},
passwordResetCode: {
create: passwordResetCodeCreate,
findFirst: passwordResetCodeFindFirst,
updateMany: passwordResetCodeUpdateMany,
},
auditLog: {
create: auditCreate,
},
@@ -78,7 +125,16 @@ async function buildApp(passwordHash) {
loginFailMaxAttempts: 5,
emailLoginCodeTtlSeconds: 600,
emailLoginCodeMaxAttempts: 5,
emailLoginCodeFailWindowSeconds: 1800,
emailLoginCodeLockSeconds: 1800,
passwordResetCodeTtlSeconds: 600,
passwordResetFailWindowSeconds: 1800,
passwordResetMaxAttempts: 5,
passwordResetLockSeconds: 1800,
passwordResetRequestWindowSeconds: 3600,
passwordResetRequestMax: 3,
authCodeRequestCooldownSeconds: 60,
devShowPasswordResetCode: false,
emailEnabled: true,
emailFrom: "[email protected]",
smtpHost: "smtp.hyinda.test",
@@ -88,31 +144,32 @@ async function buildApp(passwordHash) {
smtpPass: "",
rateLimitEnabled: false,
jwtSecret: JWT_SECRET,
authCodePepper: AUTH_CODE_PEPPER,
appOrigin: "http://127.0.0.1:3000",
wecomAllowedReturnHosts: "127.0.0.1:3000",
});
const transaction = vi.fn(async (callback) => callback(tx));
app.decorate("prisma", {
employee: {
findFirst: employeeFindFirst,
findUnique: employeeFindUnique,
findMany: employeeFindMany,
},
emailLoginCode: {
create: emailLoginCodeCreate,
findFirst: emailLoginCodeFindFirst,
},
passwordResetCode: {
create: passwordResetCodeCreate,
},
auditLog: {
create: auditCreate,
},
notification: {
createMany: notificationCreateMany,
},
$transaction: vi.fn(async (callback) => callback(tx)),
authSession: {
findFirst: authSessionFindFirst,
},
$transaction: transaction,
});
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
app.decorate("authenticate", async () => undefined);
app.decorate("authenticate", async (request) => {
request.employee = { id: EMPLOYEE_ID };
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
@@ -131,8 +188,15 @@ async function buildApp(passwordHash) {
return {
app,
employeeFindFirst,
employeeFindUnique,
employeeFindMany,
employeeUpdate,
employeeUpdateMany,
queryRaw,
transaction,
authSessionFindFirst,
txAuthSessionFindFirst,
authSessionFindUnique,
authSessionFindMany,
authSessionUpdateMany,
authSessionCreate,
@@ -140,9 +204,10 @@ async function buildApp(passwordHash) {
notificationCreateMany,
emailLoginCodeCreate,
emailLoginCodeFindFirst,
emailLoginCodeUpdate,
emailLoginCodeUpdateMany,
passwordResetCodeCreate,
passwordResetCodeFindFirst,
passwordResetCodeUpdateMany,
wecomFindFirst,
wecomUpsert,
};
@@ -370,8 +435,8 @@ describe("auth login route", () => {
it("requests an email login code without exposing account existence", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456);
const { app, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(123456);
const { app, emailLoginCodeCreate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
@@ -388,6 +453,10 @@ describe("auth login route", () => {
code_hash: hashEmailLoginCode("123456"),
}),
});
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, used_at: null },
data: { used_at: expect.any(Date) },
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
@@ -401,7 +470,7 @@ describe("auth login route", () => {
it("rejects email login code requests when delivery is not configured before account lookup", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
const { app, transaction, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash);
app.config.emailEnabled = false;
const response = await app.inject({
@@ -413,7 +482,8 @@ describe("auth login route", () => {
expect(response.statusCode).toBe(503);
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
expect(employeeFindFirst).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
expect(queryRaw).not.toHaveBeenCalled();
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
await app.close();
@@ -421,8 +491,8 @@ describe("auth login route", () => {
it("does not reveal missing accounts when requesting an email login code", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
employeeFindFirst.mockResolvedValueOnce(null);
const { app, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce([]);
const response = await app.inject({
method: "POST",
@@ -465,7 +535,13 @@ describe("auth login route", () => {
}),
);
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null },
where: {
id: "email-code-1",
employee_id: EMPLOYEE_ID,
code_hash: hashEmailLoginCode("654321"),
used_at: null,
expires_at: { gt: expect.any(Date) },
},
data: { used_at: expect.any(Date) },
});
expect(authSessionCreate).toHaveBeenCalledWith({
@@ -492,7 +568,7 @@ describe("auth login route", () => {
it("rejects an invalid email login code and records the failed attempt", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdate, auditCreate } = await buildApp(passwordHash);
const { app, employeeUpdate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
emailLoginCodeFindFirst.mockResolvedValueOnce({
id: "email-code-1",
code_hash: hashEmailLoginCode("654321"),
@@ -511,10 +587,17 @@ describe("auth login route", () => {
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(emailLoginCodeUpdate).toHaveBeenCalledWith({
where: { id: "email-code-1" },
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null },
data: { attempts_count: { increment: 1 } },
});
expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: EMPLOYEE_ID },
data: expect.objectContaining({
email_login_code_failed_count: 1,
email_login_code_locked_until: null,
}),
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_EMAIL_LOGIN_FAILED",
@@ -527,8 +610,14 @@ describe("auth login route", () => {
it("sends password reset codes with Beijing-time expiry wording", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321);
const { app, passwordResetCodeCreate, auditCreate, notificationCreateMany } = await buildApp(passwordHash);
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(654321);
const {
app,
passwordResetCodeCreate,
passwordResetCodeUpdateMany,
auditCreate,
notificationCreateMany,
} = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
@@ -542,9 +631,17 @@ describe("auth login route", () => {
expect(passwordResetCodeCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
code_hash: sha256Hex("654321"),
code_hash: hashPasswordResetCode("654321"),
}),
});
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
where: {
employee_id: EMPLOYEE_ID,
used_at: null,
invalidated_at: null,
},
data: { invalidated_at: expect.any(Date) },
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_PASSWORD_RESET_REQUESTED",
@@ -559,7 +656,7 @@ describe("auth login route", () => {
it("rejects password reset code requests when delivery is not configured before account lookup", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeFindFirst, passwordResetCodeCreate } = await buildApp(passwordHash);
const { app, transaction, queryRaw, passwordResetCodeCreate } = await buildApp(passwordHash);
app.config.smtpHost = "";
const response = await app.inject({
@@ -571,9 +668,579 @@ describe("auth login route", () => {
expect(response.statusCode).toBe(503);
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
expect(employeeFindFirst).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
expect(queryRaw).not.toHaveBeenCalled();
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
await app.close();
});
it.each([
["missing", []],
["archived", [{ ...buildEmployee("unused"), status: "archived" }]],
[
"locked",
[
{
...buildEmployee("unused"),
password_reset_locked_until: new Date(Date.now() + 60_000),
},
],
],
])("returns the same forgot-password response without writes for a %s account", async (_case, rows) => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
queryRaw,
employeeUpdate,
passwordResetCodeCreate,
passwordResetCodeUpdateMany,
auditCreate,
} = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce(rows);
const response = await app.inject({
method: "POST",
url: "/api/auth/forgot-password",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({});
expect(employeeUpdate).not.toHaveBeenCalled();
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
await app.close();
});
it("does not create a session when password state changed after verification", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeUpdateMany, authSessionCreate } = await buildApp(passwordHash);
employeeUpdateMany.mockResolvedValueOnce({ count: 0 });
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "stale-password-login",
},
payload: {
identifier: "[email protected]",
password: "AdminPassword1234",
},
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_001");
expect(authSessionCreate).not.toHaveBeenCalled();
expect(response.headers["set-cookie"]).toBeUndefined();
await app.close();
});
it("locks and rechecks the employee before rotating a refresh session", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
employeeFindUnique,
queryRaw,
authSessionFindFirst,
authSessionFindUnique,
authSessionUpdateMany,
authSessionCreate,
} = await buildApp(passwordHash);
const activeSession = {
id: "session-1",
employee_id: EMPLOYEE_ID,
device_fingerprint: "refresh-device",
expires_at: new Date(Date.now() + 60_000),
revoked_at: null,
};
authSessionFindFirst.mockResolvedValueOnce(activeSession);
authSessionFindUnique.mockResolvedValueOnce(activeSession);
authSessionUpdateMany.mockResolvedValueOnce({ count: 1 });
const response = await app.inject({
method: "POST",
url: "/api/auth/refresh",
headers: {
cookie: "refresh_token=old-refresh-token",
origin: "http://127.0.0.1:3000",
"x-device-fingerprint": "refresh-device",
},
payload: {},
});
expect(response.statusCode).toBe(200);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(authSessionFindUnique).toHaveBeenCalledWith({
where: { id: "session-1" },
select: expect.objectContaining({ id: true, employee_id: true, revoked_at: true }),
});
expect(queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
authSessionFindUnique.mock.invocationCallOrder[0],
);
expect(employeeFindUnique).not.toHaveBeenCalled();
expect(authSessionCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "refresh-device",
}),
});
await app.close();
});
it("suppresses email login code requests during the account cooldown", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, queryRaw, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce([
{
...buildEmployee(passwordHash),
email_login_code_last_requested_at: new Date(),
},
]);
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/code",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({});
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
await app.close();
});
it("locks the account-level email code entry on the fifth failed attempt", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
queryRaw,
employeeUpdate,
emailLoginCodeFindFirst,
emailLoginCodeUpdateMany,
auditCreate,
} = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce([
{
...buildEmployee(passwordHash),
email_login_code_failed_count: 4,
email_login_code_failed_first_at: new Date(),
},
]);
emailLoginCodeFindFirst.mockResolvedValueOnce({
id: "email-code-1",
code_hash: hashEmailLoginCode("654321"),
attempts_count: 4,
});
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/verify",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "email-lock-test",
},
payload: { email: "[email protected]", code: "000000" },
});
expect(response.statusCode).toBe(400);
expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: EMPLOYEE_ID },
data: expect.objectContaining({
email_login_code_failed_count: 5,
email_login_code_locked_until: expect.any(Date),
}),
});
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null },
data: {
attempts_count: { increment: 1 },
used_at: expect.any(Date),
},
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_LOCKED" }),
});
await app.close();
});
it("does not count a failure when no active email-login challenge exists", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeUpdate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/verify",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "missing-email-challenge",
},
payload: { email: "[email protected]", code: "000000" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled();
expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
await app.close();
});
it("keeps password reset failure counts when a new request is suppressed", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, queryRaw, employeeUpdate, passwordResetCodeCreate } = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce([
{
...buildEmployee(passwordHash),
password_reset_failed_count: 4,
password_reset_failed_first_at: new Date(),
password_reset_last_requested_at: new Date(),
},
]);
const response = await app.inject({
method: "POST",
url: "/api/auth/forgot-password",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({});
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
expect(employeeUpdate).not.toHaveBeenCalled();
await app.close();
});
it("invalidates any active password reset code before creating its replacement", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(111111);
const { app, passwordResetCodeCreate, passwordResetCodeUpdateMany } = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
url: "/api/auth/forgot-password",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(passwordResetCodeCreate).toHaveBeenCalledTimes(1);
expect(passwordResetCodeCreate.mock.calls[0][0].data.code_hash).toBe(
hashPasswordResetCode("111111"),
);
expect(passwordResetCodeUpdateMany).toHaveBeenCalledTimes(1);
expect(passwordResetCodeUpdateMany.mock.invocationCallOrder[0]).toBeLessThan(
passwordResetCodeCreate.mock.invocationCallOrder[0],
);
randomSpy.mockRestore();
await app.close();
});
it("locks password recovery on the fifth failed code without rolling back the counters", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
queryRaw,
employeeUpdate,
passwordResetCodeFindFirst,
passwordResetCodeUpdateMany,
auditCreate,
} = await buildApp(passwordHash);
queryRaw.mockResolvedValueOnce([
{
...buildEmployee(passwordHash),
password_reset_failed_count: 4,
password_reset_failed_first_at: new Date(),
},
]);
passwordResetCodeFindFirst.mockResolvedValueOnce({
id: "password-reset-code-1",
code_hash: hashPasswordResetCode("654321"),
attempts_count: 4,
});
const response = await app.inject({
method: "POST",
url: "/api/auth/reset-password",
headers: { "content-type": "application/json" },
payload: {
email: "[email protected]",
code: "000000",
newPassword: "ReplacementPassword1234",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: EMPLOYEE_ID },
data: expect.objectContaining({
password_reset_failed_count: 5,
password_reset_locked_until: expect.any(Date),
}),
});
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
where: {
id: "password-reset-code-1",
used_at: null,
invalidated_at: null,
},
data: {
attempts_count: { increment: 1 },
invalidated_at: expect.any(Date),
},
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_LOCKED" }),
});
await app.close();
});
it("does not count a failure when no active password-reset challenge exists", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeUpdate, passwordResetCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
url: "/api/auth/reset-password",
headers: { "content-type": "application/json" },
payload: {
email: "[email protected]",
code: "000000",
newPassword: "ReplacementPassword1234",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled();
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
await app.close();
});
it("returns the same reset error for missing, inactive, locked, and wrong-code accounts", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, queryRaw, passwordResetCodeFindFirst } = await buildApp(passwordHash);
queryRaw
.mockReset()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ ...buildEmployee(passwordHash), status: "disabled" }])
.mockResolvedValueOnce([
{
...buildEmployee(passwordHash),
password_reset_locked_until: new Date(Date.now() + 60_000),
},
])
.mockResolvedValueOnce([buildEmployee(passwordHash)]);
passwordResetCodeFindFirst.mockResolvedValueOnce({
id: "password-reset-code-1",
code_hash: hashPasswordResetCode("654321"),
attempts_count: 0,
});
const errors = [];
for (const email of [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
]) {
const response = await app.inject({
method: "POST",
url: "/api/auth/reset-password",
headers: { "content-type": "application/json" },
payload: {
email,
code: "000000",
newPassword: "ReplacementPassword1234",
},
});
expect(response.statusCode).toBe(400);
errors.push(response.json().error);
}
for (const error of errors) {
expect(error).toEqual(errors[0]);
}
await app.close();
});
it("resets the password once and revokes every challenge and refresh session", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
employeeUpdate,
authSessionUpdateMany,
emailLoginCodeUpdateMany,
passwordResetCodeFindFirst,
passwordResetCodeUpdateMany,
auditCreate,
} = await buildApp(passwordHash);
passwordResetCodeFindFirst.mockResolvedValueOnce({
id: "password-reset-code-1",
code_hash: hashPasswordResetCode("654321"),
attempts_count: 0,
});
const response = await app.inject({
method: "POST",
url: "/api/auth/reset-password",
headers: { "content-type": "application/json" },
payload: {
email: "[email protected]",
code: "654321",
newPassword: "ReplacementPassword1234",
},
});
expect(response.statusCode).toBe(200);
const passwordUpdate = employeeUpdate.mock.calls.find(
([argument]) => typeof argument.data.password_hash === "string",
)[0];
expect(await verifyPassword("ReplacementPassword1234", passwordUpdate.data.password_hash)).toBe(true);
expect(passwordUpdate.data).toEqual(
expect.objectContaining({
token_version: { increment: 1 },
password_reset_failed_count: 0,
password_reset_request_count: 0,
email_login_code_failed_count: 0,
}),
);
expect(authSessionUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, revoked_at: null },
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
});
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, invalidated_at: null },
data: { invalidated_at: expect.any(Date) },
});
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, used_at: null },
data: { used_at: expect.any(Date) },
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_SUCCEEDED" }),
});
await app.close();
});
it("allows only one concurrent consumer of the same password reset code", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
passwordResetCodeFindFirst,
passwordResetCodeUpdateMany,
auditCreate,
} = await buildApp(passwordHash);
passwordResetCodeFindFirst.mockResolvedValue({
id: "password-reset-code-1",
code_hash: hashPasswordResetCode("654321"),
attempts_count: 0,
});
let consumed = false;
passwordResetCodeUpdateMany.mockImplementation(async ({ where, data }) => {
if (where.id && data.used_at) {
if (consumed) return { count: 0 };
consumed = true;
}
return { count: 1 };
});
const responses = await Promise.all(
Array.from({ length: 2 }, () =>
app.inject({
method: "POST",
url: "/api/auth/reset-password",
headers: { "content-type": "application/json" },
payload: {
email: "[email protected]",
code: "654321",
newPassword: "ReplacementPassword1234",
},
}),
),
);
expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 400]);
expect(
auditCreate.mock.calls.filter(
([argument]) => argument.data.action === "AUTH_PASSWORD_RESET_SUCCEEDED",
),
).toHaveLength(1);
await app.close();
});
it("invalidates outstanding challenges when the user changes the password", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const {
app,
employeeUpdateMany,
authSessionUpdateMany,
authSessionCreate,
emailLoginCodeUpdateMany,
passwordResetCodeUpdateMany,
} = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
url: "/api/auth/change-password",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "change-password-device",
},
payload: {
oldPassword: "AdminPassword1234",
newPassword: "ChangedPassword1234",
},
});
expect(response.statusCode).toBe(200);
expect(employeeUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: EMPLOYEE_ID,
password_hash: passwordHash,
token_version: 3,
}),
}),
);
expect(authSessionUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, revoked_at: null },
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
});
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, invalidated_at: null },
data: { invalidated_at: expect.any(Date) },
});
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: EMPLOYEE_ID, used_at: null },
data: { used_at: expect.any(Date) },
});
expect(authSessionCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ device_fingerprint: "change-password-device" }),
});
await app.close();
});
});
+67 -5
查看文件
@@ -58,6 +58,9 @@ async function buildApp({
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
const employeeRoleFindMany = vi.fn().mockResolvedValue([{ role_code: "employee" }]);
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const tx = {
department: {
@@ -75,7 +78,13 @@ async function buildApp({
create: auditCreate,
},
authSession: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: authSessionUpdateMany,
},
passwordResetCode: {
updateMany: passwordResetCodeUpdateMany,
},
emailLoginCode: {
updateMany: emailLoginCodeUpdateMany,
},
};
@@ -134,6 +143,10 @@ async function buildApp({
employeeUpdate,
employeeRoleCreateMany,
employeeRoleFindMany,
auditCreate,
authSessionUpdateMany,
passwordResetCodeUpdateMany,
emailLoginCodeUpdateMany,
};
}
@@ -147,7 +160,7 @@ describe("usersRoutes write flows", () => {
payload: {
name: "张三",
phone: "13800000001",
email: "zhangsan@example.com",
email: "ZhangSan@Example.COM",
roles: ["employee"],
},
});
@@ -155,11 +168,10 @@ describe("usersRoutes write flows", () => {
expect(response.statusCode).toBe(200);
expect(employeeCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({
employee_no: expect.anything(),
}),
data: expect.objectContaining({ email: "[email protected]" }),
}),
);
expect(employeeCreate.mock.calls[0][0].data).not.toHaveProperty("employee_no");
expect(employeeRoleCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [
@@ -210,4 +222,54 @@ describe("usersRoutes write flows", () => {
await app.close();
});
it("revokes sessions and outstanding challenges during an administrator password reset", async () => {
const {
app,
employeeUpdate,
authSessionUpdateMany,
passwordResetCodeUpdateMany,
emailLoginCodeUpdateMany,
auditCreate,
} = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/users/${USER_ID}/reset-password`,
payload: { newPassword: "AdministratorReset1234" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
id: USER_ID,
oneTimePassword: "AdministratorReset1234",
});
expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: USER_ID },
data: expect.objectContaining({
must_change_password: true,
token_version: { increment: 1 },
email_login_code_failed_count: 0,
password_reset_failed_count: 0,
password_reset_request_count: 0,
}),
});
expect(authSessionUpdateMany).toHaveBeenCalledWith({
where: { employee_id: USER_ID, revoked_at: null },
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
});
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: USER_ID, invalidated_at: null },
data: { invalidated_at: expect.any(Date) },
});
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { employee_id: USER_ID, used_at: null },
data: { used_at: expect.any(Date) },
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "SYSTEM_USER_PASSWORD_RESET" }),
});
await app.close();
});
});
@@ -0,0 +1,200 @@
const fs = require("node:fs");
const path = require("node:path");
const {
AuthConcurrencyCheckError,
validateAuthConcurrencyEnvironment,
validateAuthRegressionEnvironment,
} = require("../../../src/scripts/check-auth-postgres-concurrency");
const REPO_ROOT = path.resolve(__dirname, "../../../..");
function validEnv() {
return {
RUN_AUTH_POSTGRES_CONCURRENCY: "1",
RUN_AUTH_SECURITY_REGRESSION: "1",
NODE_ENV: "test",
RATE_LIMIT_ENABLED: "false",
DEV_SHOW_PASSWORD_RESET_CODE: "false",
TRUST_PROXY_HOPS: "0",
EMAIL_ENABLED: "false",
SMTP_HOST: "",
SMTP_USER: "",
SMTP_PASS: "",
IM_NOTIFIER_ENABLED: "false",
WECOM_ENABLED: "false",
WECOM_AGENT_SECRET: "",
OIDC_ENABLED: "false",
OIDC_CLIENT_SECRET: "",
DATABASE_URL:
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test?schema=public",
AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_test",
BASE_URL: "http://127.0.0.1:3456",
AUTH_CODE_PEPPER: "test-auth-code-pepper-with-at-least-32-bytes",
JWT_SECRET: "independent-test-jwt-secret",
FIELD_ENCRYPTION_KEY: "test-field-encryption-key",
AUTH_SECURITY_EMPLOYEE_ID: "11111111-1111-4111-8111-111111111111",
AUTH_SECURITY_EMPLOYEE_EMAIL: "[email protected]",
AUTH_SECURITY_CURRENT_PASSWORD: "SyntheticPassword1234",
};
}
function expectSafeFailure(env, expectedCode) {
try {
validateAuthConcurrencyEnvironment(env);
throw new Error("expected validation to fail");
} catch (error) {
expect(error).toBeInstanceOf(AuthConcurrencyCheckError);
expect(error.safeCode).toBe(expectedCode);
expect(error.message).toBe(expectedCode);
}
}
describe("auth PostgreSQL concurrency guard", () => {
it("accepts only an explicitly confirmed isolated loopback target", () => {
expect(validateAuthConcurrencyEnvironment(validEnv())).toEqual(
expect.objectContaining({
baseUrl: "http://127.0.0.1:3456",
employeeId: "11111111-1111-4111-8111-111111111111",
}),
);
});
it("does not run without the explicit opt-in", () => {
expectSafeFailure(
{ ...validEnv(), RUN_AUTH_POSTGRES_CONCURRENCY: "0" },
"explicit_opt_in_required",
);
});
it("validates the base regression target before employee-scoped checks", () => {
const env = validEnv();
delete env.AUTH_SECURITY_EMPLOYEE_ID;
delete env.AUTH_SECURITY_EMPLOYEE_EMAIL;
delete env.AUTH_SECURITY_CURRENT_PASSWORD;
expect(validateAuthRegressionEnvironment(env)).toEqual(
expect.objectContaining({ baseUrl: "http://127.0.0.1:3456" }),
);
expectSafeFailure(
{ ...env, RUN_AUTH_SECURITY_REGRESSION: "0" },
"regression_opt_in_required",
);
});
it("refuses production and enabled rate limiting", () => {
expectSafeFailure({ ...validEnv(), NODE_ENV: "production" }, "test_environment_required");
expectSafeFailure(
{ ...validEnv(), RATE_LIMIT_ENABLED: "true" },
"rate_limit_must_be_disabled",
);
});
it("refuses inherited proxy and external notification providers", () => {
expectSafeFailure(
{ ...validEnv(), TRUST_PROXY_HOPS: "1" },
"trust_proxy_must_be_disabled",
);
expectSafeFailure(
{ ...validEnv(), EMAIL_ENABLED: "true" },
"email_provider_must_be_disabled",
);
expectSafeFailure(
{ ...validEnv(), SMTP_HOST: "smtp.example.test" },
"smtp_host_forbidden",
);
expectSafeFailure(
{ ...validEnv(), IM_NOTIFIER_ENABLED: "true" },
"im_notifier_must_be_disabled",
);
expectSafeFailure(
{ ...validEnv(), OIDC_ENABLED: "true" },
"oidc_must_be_disabled",
);
});
it("refuses remote PostgreSQL authority and query-host overrides", () => {
expectSafeFailure(
{
...validEnv(),
DATABASE_URL:
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test",
},
"database_host_not_loopback",
);
expectSafeFailure(
{
...validEnv(),
DATABASE_URL:
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test?host=db.internal",
},
"database_host_not_loopback",
);
});
it("refuses non-isolated or unconfirmed database names", () => {
expectSafeFailure(
{
...validEnv(),
DATABASE_URL: "postgresql://test-user:[email protected]:5432/hyb_oa_dev",
AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_dev",
},
"database_name_not_isolated",
);
expectSafeFailure(
{ ...validEnv(), AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_other" },
"database_confirmation_mismatch",
);
});
it("refuses a remote API or enabled code logging", () => {
expectSafeFailure(
{ ...validEnv(), BASE_URL: "https://oa.example.test" },
"base_url_not_loopback",
);
expectSafeFailure(
{ ...validEnv(), DEV_SHOW_PASSWORD_RESET_CODE: "true" },
"code_logging_forbidden",
);
});
it("keeps the HTTP regression fail-closed and redacted", () => {
const source = fs.readFileSync(
path.join(REPO_ROOT, "server/scripts/regression-auth-security.sh"),
"utf8",
);
const guardIndex = source.indexOf("validateAuthRegressionEnvironment");
const firstApiWriteIndex = source.indexOf("$BASE_URL/api/auth/login");
expect(guardIndex).toBeGreaterThan(-1);
expect(firstApiWriteIndex).toBeGreaterThan(guardIndex);
expect(source).not.toMatch(/cat\s+[^\n]*body/i);
expect(source).toContain("[AUTH-REGRESSION][PARTIAL] BASE_ONLY");
expect(source).toContain("[AUTH-REGRESSION][PASS] FULL");
expect(source).toContain("invalid_concurrency_mode");
});
it("keeps auth-security-local isolated and cleanup failures authoritative", () => {
const source = fs.readFileSync(
path.join(REPO_ROOT, "scripts/qa/run-web-gates.sh"),
"utf8",
);
const dropFunction = source.slice(
source.indexOf("qa_dropdb()"),
source.indexOf("qa_cleanup_server()"),
);
expect(source).toContain("hyb-oa-web-gates-server-${QA_RUN_ID}.pid");
expect(source).toContain('QA_SERVER_STARTED=0');
expect(source).toContain('QA_SERVER_OWNED=0');
expect(source).toContain('export TRUST_PROXY_HOPS=0');
expect(source).toContain('export EMAIL_ENABLED=false');
expect(source).toContain('export OIDC_ENABLED=false');
expect(source).toContain('unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY');
expect(source).toContain('QA_SERVER_ENTRY="src/scripts/start-auth-security-test-server.js"');
expect(source).toContain('RUN_AUTH_POSTGRES_CONCURRENCY=1 ./scripts/regression-auth-security.sh');
expect(source).toContain('exit "$cleanup_status"');
expect(dropFunction).not.toContain("|| true");
expect(dropFunction).toContain("return 1");
});
});