feat: R27-1 收口企业微信入口配置状态

这个提交包含在:
2026-03-22 19:52:39 +08:00
父节点 08ae013b9e
当前提交 eaef213d22
共修改 4 个文件,包含 338 行新增和 25 行删除
+81 -23
查看文件
@@ -9,6 +9,8 @@ const { sendData } = require("../utils/reply");
const BIND_COOKIE_NAME = "wecom_bind";
const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/portal";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
const QUERY_SCHEMA = z.object({
returnTo: z.string().optional(),
mode: z.enum(["login", "bind"]).optional(),
@@ -58,7 +60,6 @@ function parseAllowedReturnHosts(config, request) {
try {
hosts.add(new URL(item).host);
} catch {
// ignore invalid origin
}
}
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
@@ -68,7 +69,6 @@ function parseAllowedReturnHosts(config, request) {
try {
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
} catch {
// ignore
}
}
return hosts;
@@ -76,39 +76,102 @@ function parseAllowedReturnHosts(config, request) {
function sanitizeReturnTo(rawValue, config, request) {
if (typeof rawValue !== "string") {
return "/portal";
return DEFAULT_RETURN_TO;
}
const trimmed = rawValue.trim();
if (!trimmed) {
return "/portal";
return DEFAULT_RETURN_TO;
}
if (trimmed.startsWith("/")) {
return trimmed.startsWith("//") ? "/portal" : trimmed;
return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed;
}
try {
const url = new URL(trimmed);
const allowedHosts = parseAllowedReturnHosts(config, request);
if (!allowedHosts.has(url.host)) {
return "/portal";
return DEFAULT_RETURN_TO;
}
return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`;
return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`;
} catch {
return "/portal";
return DEFAULT_RETURN_TO;
}
}
function assertWecomEnabled(config) {
function resolveWecomEntryConfig(config) {
const statusReasons = [];
if (!config.wecomEnabled) {
statusReasons.push("wecom_disabled");
}
if (!config.wecomEntryEnabled) {
statusReasons.push("entry_disabled");
}
if (!config.wecomOauthEnabled) {
statusReasons.push("oauth_disabled");
}
if (!config.wecomCorpId) {
statusReasons.push("corp_id_missing");
}
if (!config.wecomAgentId) {
statusReasons.push("agent_id_missing");
}
if (!config.wecomRedirectUri) {
statusReasons.push("redirect_uri_missing");
}
const bindEnabled = Boolean(
config.wecomEnabled
&& config.wecomOauthEnabled
&& config.wecomCorpId
&& config.wecomAgentId
&& config.wecomRedirectUri,
);
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
return {
enabled: entryEnabled,
entryEnabled,
oauthEnabled: Boolean(config.wecomOauthEnabled),
corpId: String(config.wecomCorpId ?? ""),
agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
bindEnabled,
defaultReturnTo: DEFAULT_RETURN_TO,
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
statusReasons,
};
}
function assertWecomOauthReady(config) {
const entryConfig = resolveWecomEntryConfig(config);
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
if (!entryConfig.bindEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { wecom: "missing_required_config" },
details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons },
});
}
return entryConfig;
}
function assertWecomEntryReady(config) {
const entryConfig = assertWecomOauthReady(config);
if (!config.wecomEntryEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return entryConfig;
}
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
const params = new URLSearchParams({ returnTo });
if (mode === "bind") {
params.set("mode", "bind");
}
return `/api/auth/wecom/login?${params.toString()}`;
}
async function getWecomAccessToken(config) {
@@ -218,30 +281,25 @@ async function createAuthenticatedSession({ prisma, request, config, employeeId,
async function wecomAuthRoutes(fastify) {
fastify.get("/wecom/config", async (request, reply) => {
const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri);
return sendData(reply, request, {
enabled,
corpId: enabled ? fastify.config.wecomCorpId : "",
agentId: enabled ? fastify.config.wecomAgentId : "",
oauthEnabled: enabled,
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
});
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
});
fastify.get("/wecom/login", async (request, reply) => {
assertWecomEnabled(fastify.config);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
}
const mode = parsed.data.mode ?? "login";
if (mode === "bind") {
assertWecomOauthReady(fastify.config);
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
if (!bindState.ok) {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
} else {
assertWecomEntryReady(fastify.config);
}
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const state = signPayload({
@@ -254,7 +312,7 @@ async function wecomAuthRoutes(fastify) {
});
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
assertWecomEnabled(fastify.config);
assertWecomOauthReady(fastify.config);
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
@@ -267,7 +325,7 @@ async function wecomAuthRoutes(fastify) {
}, fastify.config.jwtSecret);
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
return sendData(reply, request, {
loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`,
loginUrl: buildWecomLoginUrl(returnTo, "bind"),
});
});
@@ -290,7 +348,7 @@ async function wecomAuthRoutes(fastify) {
});
fastify.get("/wecom/callback", async (request, reply) => {
assertWecomEnabled(fastify.config);
assertWecomOauthReady(fastify.config);
const stateToken = String(request.query?.state ?? "").trim();
const code = String(request.query?.code ?? "").trim();
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
+243
查看文件
@@ -0,0 +1,243 @@
const Fastify = require("fastify");
const fastifyCookie = require("@fastify/cookie");
const { AppError } = require("../../../src/utils/AppError");
const wecomAuthRoutes = require("../../../src/routes/wecomAuth");
const { signPayload, verifyToken } = require("../../../src/utils/signedUrl");
const NOW = new Date("2026-03-22T11:00:00.000Z");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const JWT_SECRET = "test-wecom-secret";
const originalFetch = global.fetch;
function buildPrisma(overrides = {}) {
return {
wecomIdentityBinding: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
...(overrides.wecomIdentityBinding ?? {}),
},
employee: {
findUnique: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active" }),
...(overrides.employee ?? {}),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
...(overrides.auditLog ?? {}),
},
...overrides,
};
}
async function buildApp({ config = {}, prisma = buildPrisma() } = {}) {
const app = Fastify();
await app.register(fastifyCookie);
app.decorate("config", {
appOrigin: "https://oa.hyinda.com",
jwtSecret: JWT_SECRET,
refreshCookieName: "refresh_token",
refreshTtlSeconds: 7 * 24 * 60 * 60,
cookieSecure: true,
wecomEnabled: true,
wecomEntryEnabled: true,
wecomOauthEnabled: true,
wecomCorpId: "wx-test-corp",
wecomAgentId: "1000001",
wecomAgentSecret: "wecom-secret",
wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback",
wecomAllowedReturnHosts: "oa.hyinda.com",
...config,
});
app.decorate("prisma", prisma);
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: EMPLOYEE_ID };
});
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: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
return app;
}
function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: "access-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }),
});
}
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("wecom auth routes R27", () => {
it("returns entry-disabled config while keeping bind available", async () => {
const app = await buildApp({ config: { wecomEntryEnabled: false } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
entryEnabled: false,
bindEnabled: true,
oauthEnabled: true,
status: "disabled",
defaultReturnTo: "/portal",
loginUrl: null,
statusReasons: expect.arrayContaining(["entry_disabled"]),
}));
await app.close();
});
it("returns misconfigured status when corp id is missing", async () => {
const app = await buildApp({ config: { wecomCorpId: "" } });
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
enabled: false,
bindEnabled: false,
status: "misconfigured",
statusReasons: expect.arrayContaining(["corp_id_missing"]),
corpId: "",
agentId: "1000001",
}));
await app.close();
});
it("sanitizes login returnTo to allowed hosts or local paths", async () => {
const app = await buildApp();
const response = await app.inject({
method: "GET",
url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal",
});
expect(response.statusCode).toBe(302);
const location = response.headers.location;
expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize");
const state = new URL(location).searchParams.get("state");
const verified = verifyToken(state, JWT_SECRET);
expect(verified.ok).toBe(true);
expect(verified.payload.returnTo).toBe("/portal");
expect(verified.payload.mode).toBe("login");
await app.close();
});
it("redirects unbound users back to login with wecom hint", async () => {
mockWecomFetch({ userId: "wecom-unbound" });
const prisma = buildPrisma({
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-login",
returnTo: "/portal",
mode: "login",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
await app.close();
});
it("creates bind audit log on successful wecom bind callback", async () => {
mockWecomFetch({ userId: "wecom-bind-1" });
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const state = signPayload({
exp: Date.now() + 60_000,
nonce: "nonce-bind",
returnTo: "/settings/profile",
mode: "bind",
}, JWT_SECRET);
const bindCookie = signPayload({
exp: Date.now() + 60_000,
employeeId: EMPLOYEE_ID,
returnTo: "/settings/profile",
}, JWT_SECRET);
const response = await app.inject({
method: "GET",
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
headers: {
cookie: `wecom_bind=${bindCookie}`,
},
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success");
expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled();
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_BIND",
target_type: "employee",
target_id: EMPLOYEE_ID,
}),
}));
await app.close();
});
it("writes audit log when unbinding wecom identity", async () => {
const prisma = buildPrisma();
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/auth/wecom/unbind",
headers: {
authorization: "Bearer test-token",
},
payload: {},
});
expect(response.statusCode).toBe(200);
expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } });
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
actor_id: EMPLOYEE_ID,
action: "AUTH_WECOM_UNBIND",
}),
}));
await app.close();
});
});