增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-30 17:16:22 +08:00
父节点 45dc206a4c
当前提交 fb164eb690
共修改 21 个文件,包含 959 行新增和 22 行删除
+115 -7
查看文件
@@ -19,13 +19,18 @@ const {
const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
const PC_LOGIN_STATE_COOKIE_NAME = "wecom_pc_login_state";
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/mobile";
const PC_DEFAULT_RETURN_TO = "/portal";
const ENTRY_SOURCE_WECOM_WORKBENCH = "wecom_workbench";
const ENTRY_SOURCE_PC_QR = "pc_qr";
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", "onboard"]).optional(),
entrySource: z.enum([ENTRY_SOURCE_WECOM_WORKBENCH, ENTRY_SOURCE_PC_QR]).optional(),
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
});
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
@@ -70,6 +75,16 @@ function pendingLoginBindCookieOptions(config) {
};
}
function pcLoginStateCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth/wecom",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function deviceFingerprintCookieOptions(config) {
return {
httpOnly: false,
@@ -169,6 +184,7 @@ function resolveWecomEntryConfig(config) {
&& config.wecomRedirectUri,
);
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const pcWebLoginEnabled = Boolean(entryEnabled && config.wecomPcWebLoginEnabled);
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
@@ -179,6 +195,10 @@ function resolveWecomEntryConfig(config) {
corpId: String(config.wecomCorpId ?? ""),
agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
pcWebLoginEnabled,
pcLoginUrl: pcWebLoginEnabled
? buildWecomLoginUrl(PC_DEFAULT_RETURN_TO, "login", ENTRY_SOURCE_PC_QR)
: null,
bindEnabled,
onboardingEnabled,
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
@@ -212,6 +232,14 @@ function assertWecomEntryReady(config) {
return entryConfig;
}
function assertWecomPcWebLoginReady(config) {
const entryConfig = assertWecomEntryReady(config);
if (!entryConfig.pcWebLoginEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return entryConfig;
}
function assertWecomOnboardingReady(config) {
assertWecomOauthReady(config);
if (!config.wecomOauthOnboardingEnabled) {
@@ -219,11 +247,14 @@ function assertWecomOnboardingReady(config) {
}
}
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entrySource = ENTRY_SOURCE_WECOM_WORKBENCH) {
const params = new URLSearchParams({ returnTo });
if (mode === "bind" || mode === "onboard") {
params.set("mode", mode);
}
if (entrySource === ENTRY_SOURCE_PC_QR) {
params.set("entrySource", ENTRY_SOURCE_PC_QR);
}
return `/api/auth/wecom/login?${params.toString()}`;
}
@@ -328,6 +359,48 @@ function buildWecomAuthorizeUrl(config, state, mode = "login") {
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`;
}
function buildWecomPcAuthorizeUrl(config, state) {
const url = new URL("https://login.work.weixin.qq.com/wwlogin/sso/login");
url.searchParams.set("login_type", "CorpApp");
url.searchParams.set("appid", config.wecomCorpId);
url.searchParams.set("agentid", config.wecomAgentId);
url.searchParams.set("redirect_uri", config.wecomRedirectUri);
url.searchParams.set("state", state);
return url.toString();
}
function safeEqualString(left, right) {
if (typeof left !== "string" || typeof right !== "string") return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function resolveCallbackEntrySource(value) {
if (value == null) return ENTRY_SOURCE_WECOM_WORKBENCH;
if (value === ENTRY_SOURCE_WECOM_WORKBENCH || value === ENTRY_SOURCE_PC_QR) return value;
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
function validatePcLoginStateCookie({ request, reply, config, ctx }) {
const cookieToken = request.cookies?.[PC_LOGIN_STATE_COOKIE_NAME];
const verified = verifyToken(cookieToken, config.jwtSecret);
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(config));
assertWecomPcWebLoginReady(config);
if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const payload = verified.payload ?? {};
const valid = payload.entrySource === ENTRY_SOURCE_PC_QR
&& typeof payload.exp === "number"
&& payload.exp > Date.now()
&& safeEqualString(payload.nonce, ctx.nonce);
if (!valid) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
}
function resolveOAuthDeviceFingerprint(value) {
if (typeof value === "string" && value.trim()) {
return value.trim();
@@ -398,6 +471,15 @@ async function wecomAuthRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
}
const mode = parsed.data.mode ?? "login";
const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH;
if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { entrySource: "unsupported_for_mode" },
});
}
if (mode === "bind") {
assertWecomOauthReady(fastify.config);
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
@@ -408,18 +490,28 @@ async function wecomAuthRoutes(fastify) {
}
} else if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config);
} else if (entrySource === ENTRY_SOURCE_PC_QR) {
assertWecomPcWebLoginReady(fastify.config);
} else {
assertWecomEntryReady(fastify.config);
}
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
const exp = Date.now() + DATE_WINDOW_MS;
const nonce = crypto.randomUUID();
const state = signPayload({
exp: Date.now() + DATE_WINDOW_MS,
nonce: crypto.randomUUID(),
exp,
nonce,
returnTo,
mode,
entrySource,
deviceFingerprint,
}, fastify.config.jwtSecret);
if (entrySource === ENTRY_SOURCE_PC_QR) {
const correlationToken = signPayload({ exp, nonce, entrySource }, fastify.config.jwtSecret);
reply.setCookie(PC_LOGIN_STATE_COOKIE_NAME, correlationToken, pcLoginStateCookieOptions(fastify.config));
return reply.redirect(buildWecomPcAuthorizeUrl(fastify.config, state));
}
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
});
@@ -464,14 +556,28 @@ async function wecomAuthRoutes(fastify) {
const stateToken = String(request.query?.state ?? "").trim();
const code = String(request.query?.code ?? "").trim();
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
if (!verified.ok || !code) {
if (!verified.ok) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const ctx = verified.payload ?? {};
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const entrySource = resolveCallbackEntrySource(ctx.entrySource);
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
if (entrySource === ENTRY_SOURCE_PC_QR) {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
}
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
if (entrySource === ENTRY_SOURCE_PC_QR) {
if (mode !== "login") {
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
validatePcLoginStateCookie({ request, reply, config: fastify.config, ctx });
}
if (!code) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
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) {
@@ -479,6 +585,7 @@ async function wecomAuthRoutes(fastify) {
{
requestId: request.id,
mode,
entrySource,
hasUserId: false,
hasOpenId: Boolean(profile.externalOpenUserId),
hasDeviceId: Boolean(profile.deviceId),
@@ -497,6 +604,7 @@ async function wecomAuthRoutes(fastify) {
wecom: "missing_user_id",
hasOpenId: Boolean(profile.externalOpenUserId),
mode,
entrySource,
},
});
}
@@ -645,7 +753,7 @@ async function wecomAuthRoutes(fastify) {
? "account_mapping_inactive"
: "account_mapping_required";
request.log.warn(
{ requestId: request.id, errorCode: error.code, notice },
{ requestId: request.id, errorCode: error.code, notice, entrySource },
"wecom automatic login binding failed closed",
);
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
@@ -664,7 +772,7 @@ async function wecomAuthRoutes(fastify) {
tokenVersion: loginEmployee.token_version,
deviceFingerprint,
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource },
extra: { bindingSource, entrySource },
});
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));