feat: R27-1 收口企业微信入口配置状态
这个提交包含在:
+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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户