412 行
13 KiB
JavaScript
412 行
13 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const Fastify = require("fastify");
|
|
const fastifyCookie = require("@fastify/cookie");
|
|
|
|
const authRoutes = require("../../../src/routes/auth");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
const { hashPassword } = require("../../../src/utils/crypto");
|
|
const { signPayload } = require("../../../src/utils/signedUrl");
|
|
|
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
|
const JWT_SECRET = "test-auth-secret";
|
|
|
|
function hashEmailLoginCode(code) {
|
|
return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex");
|
|
}
|
|
|
|
async function buildApp(passwordHash) {
|
|
const app = Fastify();
|
|
await app.register(fastifyCookie);
|
|
|
|
const employeeFindFirst = vi.fn().mockResolvedValue({
|
|
id: EMPLOYEE_ID,
|
|
status: "active",
|
|
password_hash: passwordHash,
|
|
token_version: 3,
|
|
must_change_password: false,
|
|
login_failed_count: 0,
|
|
login_failed_first_at: null,
|
|
login_locked_until: null,
|
|
});
|
|
const employeeUpdate = vi.fn().mockResolvedValue({});
|
|
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
|
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
|
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-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 wecomFindFirst = vi.fn().mockResolvedValue(null);
|
|
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
|
|
|
const tx = {
|
|
employee: {
|
|
update: employeeUpdate,
|
|
},
|
|
authSession: {
|
|
findMany: authSessionFindMany,
|
|
updateMany: authSessionUpdateMany,
|
|
create: authSessionCreate,
|
|
},
|
|
emailLoginCode: {
|
|
update: emailLoginCodeUpdate,
|
|
updateMany: emailLoginCodeUpdateMany,
|
|
},
|
|
auditLog: {
|
|
create: auditCreate,
|
|
},
|
|
wecomIdentityBinding: {
|
|
findFirst: wecomFindFirst,
|
|
upsert: wecomUpsert,
|
|
},
|
|
};
|
|
|
|
app.decorate("config", {
|
|
accessTokenTtl: "15m",
|
|
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
|
refreshCookieName: "refresh_token",
|
|
cookieSecure: false,
|
|
loginFailWindowSeconds: 300,
|
|
loginLockSeconds: 900,
|
|
loginFailMaxAttempts: 5,
|
|
emailLoginCodeTtlSeconds: 600,
|
|
emailLoginCodeMaxAttempts: 5,
|
|
rateLimitEnabled: false,
|
|
jwtSecret: JWT_SECRET,
|
|
appOrigin: "http://127.0.0.1:3000",
|
|
wecomAllowedReturnHosts: "127.0.0.1:3000",
|
|
});
|
|
app.decorate("prisma", {
|
|
employee: {
|
|
findFirst: employeeFindFirst,
|
|
},
|
|
emailLoginCode: {
|
|
create: emailLoginCodeCreate,
|
|
findFirst: emailLoginCodeFindFirst,
|
|
},
|
|
auditLog: {
|
|
create: auditCreate,
|
|
},
|
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
|
});
|
|
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
|
app.decorate("authenticate", async () => undefined);
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
|
});
|
|
}
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
|
});
|
|
});
|
|
|
|
await app.register(authRoutes, { prefix: "/api/auth" });
|
|
|
|
return {
|
|
app,
|
|
employeeFindFirst,
|
|
employeeUpdate,
|
|
authSessionFindMany,
|
|
authSessionUpdateMany,
|
|
authSessionCreate,
|
|
auditCreate,
|
|
emailLoginCodeCreate,
|
|
emailLoginCodeFindFirst,
|
|
emailLoginCodeUpdate,
|
|
emailLoginCodeUpdateMany,
|
|
wecomFindFirst,
|
|
wecomUpsert,
|
|
};
|
|
}
|
|
|
|
describe("auth login route", () => {
|
|
it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app } = await buildApp(passwordHash);
|
|
const token = signPayload({
|
|
targetPath: "/mobile/approvals?taskId=task-1",
|
|
exp: Math.floor(Date.now() / 1000) - 60,
|
|
}, JWT_SECRET);
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(302);
|
|
expect(response.headers.location).toBe("http://127.0.0.1:3000/mobile/approvals?taskId=task-1");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("routes signed deeplink through wecom login inside Enterprise WeChat", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app } = await buildApp(passwordHash);
|
|
const token = signPayload({
|
|
targetPath: "/mobile/approvals?taskId=task-1",
|
|
exp: Math.floor(Date.now() / 1000) - 60,
|
|
}, JWT_SECRET);
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
|
headers: {
|
|
"user-agent": "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/8.0.1",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
"http://127.0.0.1:3000/api/auth/wecom/login?returnTo=%2Fmobile%2Fapprovals%3FtaskId%3Dtask-1",
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects deeplink targets outside internal OA paths", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app } = await buildApp(passwordHash);
|
|
const token = signPayload({ targetPath: "//evil.example.com/mobile" }, JWT_SECRET);
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/auth/deeplink?token=${encodeURIComponent(token)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects tampered deeplink signatures and absolute URL targets", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app } = await buildApp(passwordHash);
|
|
const validToken = signPayload({ targetPath: "/mobile" }, JWT_SECRET);
|
|
|
|
const tamperedResponse = await app.inject({
|
|
method: "GET",
|
|
url: `/api/auth/deeplink?token=${encodeURIComponent(`${validToken}x`)}`,
|
|
});
|
|
|
|
expect(tamperedResponse.statusCode).toBe(401);
|
|
expect(tamperedResponse.json().error.details).toEqual({ token: "invalid_signature" });
|
|
|
|
const absoluteUrlToken = signPayload({ targetPath: "https://evil.example.com/mobile" }, JWT_SECRET);
|
|
const absoluteUrlResponse = await app.inject({
|
|
method: "GET",
|
|
url: `/api/auth/deeplink?token=${encodeURIComponent(absoluteUrlToken)}`,
|
|
});
|
|
|
|
expect(absoluteUrlResponse.statusCode).toBe(400);
|
|
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("auto-binds pending wecom identity after password login", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
|
const pendingBindToken = signPayload({
|
|
exp: Date.now() + 60_000,
|
|
externalUserId: "wecom-user-1",
|
|
externalOpenUserId: "openid-1",
|
|
returnTo: "/portal",
|
|
}, JWT_SECRET);
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": "pw-e2e-admin",
|
|
cookie: `wecom_login_bind=${pendingBindToken}`,
|
|
},
|
|
payload: {
|
|
identifier: "[email protected]",
|
|
password: "AdminPassword1234",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(wecomFindFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
external_user_id: "wecom-user-1",
|
|
NOT: { employee_id: EMPLOYEE_ID },
|
|
},
|
|
select: { employee_id: true },
|
|
});
|
|
expect(wecomUpsert).toHaveBeenCalledWith({
|
|
where: { employee_id: EMPLOYEE_ID },
|
|
update: {
|
|
external_user_id: "wecom-user-1",
|
|
external_open_user_id: "openid-1",
|
|
updated_by: EMPLOYEE_ID,
|
|
},
|
|
create: {
|
|
employee_id: EMPLOYEE_ID,
|
|
external_user_id: "wecom-user-1",
|
|
external_open_user_id: "openid-1",
|
|
created_by: EMPLOYEE_ID,
|
|
updated_by: EMPLOYEE_ID,
|
|
},
|
|
});
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
accessToken: "access-token",
|
|
mustChangePassword: false,
|
|
wecomBindingStatus: "bound",
|
|
}),
|
|
);
|
|
expect(response.headers["set-cookie"]).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("wecom_login_bind="),
|
|
]),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
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 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).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
employee_id: EMPLOYEE_ID,
|
|
code_hash: hashEmailLoginCode("123456"),
|
|
}),
|
|
});
|
|
expect(auditCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
|
|
actor_id: EMPLOYEE_ID,
|
|
}),
|
|
});
|
|
|
|
randomSpy.mockRestore();
|
|
await app.close();
|
|
});
|
|
|
|
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 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();
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("logs in with a valid email code and creates a refresh session", async () => {
|
|
const passwordHash = await hashPassword("AdminPassword1234");
|
|
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, authSessionCreate, auditCreate } = await buildApp(passwordHash);
|
|
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
|
id: "email-code-1",
|
|
code_hash: hashEmailLoginCode("654321"),
|
|
attempts_count: 0,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/email-login/verify",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": "email-code-device",
|
|
},
|
|
payload: { email: "[email protected]", code: "654321" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
accessToken: "access-token",
|
|
mustChangePassword: false,
|
|
}),
|
|
);
|
|
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
|
where: { id: "email-code-1", used_at: null },
|
|
data: { used_at: expect.any(Date) },
|
|
});
|
|
expect(authSessionCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
employee_id: EMPLOYEE_ID,
|
|
device_fingerprint: "email-code-device",
|
|
}),
|
|
});
|
|
expect(auditCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: "AUTH_EMAIL_LOGIN_SUCCESS",
|
|
actor_id: EMPLOYEE_ID,
|
|
}),
|
|
});
|
|
expect(response.headers["set-cookie"]).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("refresh_token="),
|
|
expect.stringContaining("has_session=1"),
|
|
]),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
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);
|
|
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
|
id: "email-code-1",
|
|
code_hash: hashEmailLoginCode("654321"),
|
|
attempts_count: 0,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/email-login/verify",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": "email-code-device",
|
|
},
|
|
payload: { email: "[email protected]", code: "000000" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
|
expect(emailLoginCodeUpdate).toHaveBeenCalledWith({
|
|
where: { id: "email-code-1" },
|
|
data: { attempts_count: { increment: 1 } },
|
|
});
|
|
expect(auditCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: "AUTH_EMAIL_LOGIN_FAILED",
|
|
actor_id: EMPLOYEE_ID,
|
|
}),
|
|
});
|
|
|
|
await app.close();
|
|
});
|
|
});
|