完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
+92
-11
@@ -6,6 +6,11 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { randomRefreshToken, sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
WecomOAuthOnboardingError,
|
||||
fetchWecomOAuthOnboardingProfile,
|
||||
provisionWecomOAuthEmployee,
|
||||
} = require("../services/integrations/wecomOAuthOnboarding");
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
@@ -15,7 +20,7 @@ const DEFAULT_RETURN_TO = "/mobile";
|
||||
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(),
|
||||
mode: z.enum(["login", "bind", "onboard"]).optional(),
|
||||
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
@@ -159,6 +164,7 @@ function resolveWecomEntryConfig(config) {
|
||||
&& config.wecomRedirectUri,
|
||||
);
|
||||
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
|
||||
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
|
||||
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
|
||||
|
||||
return {
|
||||
@@ -169,6 +175,8 @@ function resolveWecomEntryConfig(config) {
|
||||
agentId: String(config.wecomAgentId ?? ""),
|
||||
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
|
||||
bindEnabled,
|
||||
onboardingEnabled,
|
||||
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
|
||||
defaultReturnTo: DEFAULT_RETURN_TO,
|
||||
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
|
||||
statusReasons,
|
||||
@@ -199,10 +207,17 @@ function assertWecomEntryReady(config) {
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomOnboardingReady(config) {
|
||||
assertWecomOauthReady(config);
|
||||
if (!config.wecomOauthOnboardingEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
if (mode === "bind") {
|
||||
params.set("mode", "bind");
|
||||
if (mode === "bind" || mode === "onboard") {
|
||||
params.set("mode", mode);
|
||||
}
|
||||
return `/api/auth/wecom/login?${params.toString()}`;
|
||||
}
|
||||
@@ -219,14 +234,24 @@ async function getWecomAccessToken(config) {
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
|
||||
url.searchParams.set("corpid", config.wecomCorpId);
|
||||
url.searchParams.set("corpsecret", config.wecomAgentSecret);
|
||||
const response = await fetch(url);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed", payload },
|
||||
details: { wecom: "token_exchange_failed" },
|
||||
});
|
||||
}
|
||||
return String(payload.access_token);
|
||||
@@ -237,14 +262,24 @@ async function exchangeWecomCode(config, code) {
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
url.searchParams.set("code", code);
|
||||
const response = await fetch(url);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed", payload },
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
});
|
||||
}
|
||||
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
|
||||
@@ -261,6 +296,8 @@ async function exchangeWecomCode(config, code) {
|
||||
externalUserId: pickCandidate(payload.UserId, payload.userid),
|
||||
externalOpenUserId: pickCandidate(payload.OpenId, payload.openid),
|
||||
deviceId: pickCandidate(payload.DeviceId, payload.deviceid),
|
||||
userTicket: pickCandidate(payload.user_ticket),
|
||||
accessToken,
|
||||
diagnostics: {
|
||||
errcode: Number(payload.errcode ?? 0),
|
||||
errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null,
|
||||
@@ -280,9 +317,9 @@ async function exchangeWecomCode(config, code) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomAuthorizeUrl(config, state) {
|
||||
function buildWecomAuthorizeUrl(config, state, mode = "login") {
|
||||
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
|
||||
const scope = "snsapi_base";
|
||||
const scope = mode === "onboard" ? "snsapi_privateinfo" : "snsapi_base";
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
@@ -364,6 +401,8 @@ async function wecomAuthRoutes(fastify) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
} else if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
} else {
|
||||
assertWecomEntryReady(fastify.config);
|
||||
}
|
||||
@@ -376,7 +415,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
mode,
|
||||
deviceFingerprint,
|
||||
}, fastify.config.jwtSecret);
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state));
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
|
||||
});
|
||||
|
||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -427,7 +466,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : "login";
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
@@ -459,6 +498,48 @@ async function wecomAuthRoutes(fastify) {
|
||||
|
||||
const origin = resolvePrimaryAppOrigin(fastify.config, request);
|
||||
|
||||
if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
if (!profile.userTicket) {
|
||||
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=consent_required`);
|
||||
}
|
||||
try {
|
||||
const onboardingProfile = await fetchWecomOAuthOnboardingProfile({
|
||||
accessToken: profile.accessToken,
|
||||
externalUserId: profile.externalUserId,
|
||||
userTicket: profile.userTicket,
|
||||
});
|
||||
const employee = await provisionWecomOAuthEmployee({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
profile: onboardingProfile,
|
||||
externalOpenUserId: profile.externalOpenUserId,
|
||||
});
|
||||
const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint);
|
||||
const session = await createAuthenticatedSession({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
config: fastify.config,
|
||||
employeeId: employee.id,
|
||||
tokenVersion: employee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_ONBOARDING_LOGIN_SUCCESS",
|
||||
extra: { created: employee.created },
|
||||
});
|
||||
reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config));
|
||||
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
|
||||
reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}`);
|
||||
} catch (error) {
|
||||
if (error instanceof WecomOAuthOnboardingError) {
|
||||
return reply.redirect(
|
||||
`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${encodeURIComponent(error.code)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
|
||||
在新工单中引用
屏蔽一个用户