修复 R7E 真机反馈回流与移动入口 WF8-11A R7E-16A R7E-16B BIZ8-12A
这个提交包含在:
+15
-10
@@ -182,6 +182,17 @@ function sanitizeReturnTo(value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isWecomWebViewRequest(request) {
|
||||
const userAgent = String(request.headers["user-agent"] ?? "").toLowerCase();
|
||||
return userAgent.includes("wxwork");
|
||||
}
|
||||
|
||||
function buildWecomLoginRedirect(origin, targetPath) {
|
||||
const params = new URLSearchParams({ returnTo: targetPath });
|
||||
const path = `/api/auth/wecom/login?${params.toString()}`;
|
||||
return origin ? `${origin}${path}` : path;
|
||||
}
|
||||
|
||||
function parseAccessTokenTtlSeconds(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
@@ -737,8 +748,7 @@ async function authRoutes(fastify) {
|
||||
|
||||
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
|
||||
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
|
||||
const exp = Number(payload.exp ?? 0);
|
||||
if (!targetPath || !targetPath.startsWith("/")) {
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
@@ -746,16 +756,11 @@ async function authRoutes(fastify) {
|
||||
details: { targetPath: "invalid_target_path" },
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(exp) || Date.now() / 1000 > exp) {
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "深链已过期",
|
||||
statusCode: 401,
|
||||
details: { token: "expired" },
|
||||
});
|
||||
}
|
||||
|
||||
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
|
||||
if (isWecomWebViewRequest(request)) {
|
||||
return reply.redirect(buildWecomLoginRedirect(origin, targetPath));
|
||||
}
|
||||
return reply.redirect(origin ? `${origin}${targetPath}` : targetPath);
|
||||
});
|
||||
|
||||
|
||||
@@ -607,12 +607,12 @@ async function portalRoutes(fastify) {
|
||||
select: { project_id: true },
|
||||
}).then((items) => items.length)
|
||||
: Promise.resolve(0),
|
||||
roleCapabilities.canUseFinance
|
||||
roleCapabilities.canUseFinance && featureEnabled.paymentBatch
|
||||
? fastify.prisma.paymentBatch.count({
|
||||
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
roleCapabilities.canUseFinance
|
||||
roleCapabilities.canUseFinance && featureEnabled.paymentBatch
|
||||
? fastify.prisma.paymentBatchItem.count({ where: { status: "failed" } })
|
||||
: Promise.resolve(0),
|
||||
buildRecentActivities(fastify.prisma),
|
||||
@@ -814,6 +814,7 @@ async function portalRoutes(fastify) {
|
||||
pendingBatchCount: pendingFinanceBatchCount,
|
||||
failedItemCount: failedFinanceItemCount,
|
||||
riskCount,
|
||||
paymentBatchEnabled: featureEnabled.paymentBatch,
|
||||
href: "/ap-operations",
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -24,7 +24,6 @@ function buildDetailUrl(config, targetPath) {
|
||||
const token = signPayload(
|
||||
{
|
||||
targetPath: normalizedPath,
|
||||
exp: Math.floor(Date.now() / 1000) + 15 * 60,
|
||||
},
|
||||
config.jwtSecret,
|
||||
);
|
||||
|
||||
@@ -99,6 +99,90 @@ async function buildApp(passwordHash) {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,12 +4,18 @@ const {
|
||||
enqueueImNotifications,
|
||||
resolveRecipientRoleCodes,
|
||||
} = require("../../../src/services/notifications/imEnqueue");
|
||||
const { verifyToken } = require("../../../src/utils/signedUrl");
|
||||
|
||||
describe("IM enqueue", () => {
|
||||
it("builds detail url from APP_ORIGIN", () => {
|
||||
expect(buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1")).toContain(
|
||||
const detailUrl = buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1");
|
||||
expect(detailUrl).toContain(
|
||||
"https://app.example.com/api/auth/deeplink?token=",
|
||||
);
|
||||
const token = new URL(detailUrl).searchParams.get("token");
|
||||
const verified = verifyToken(token, "test-secret");
|
||||
expect(verified.ok).toBe(true);
|
||||
expect(verified.payload).toEqual({ targetPath: "/workflow/tasks/1" });
|
||||
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户