修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户