增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
这个提交包含在:
@@ -253,6 +253,7 @@ function getConfig() {
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
|
||||
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_ENABLED === "true",
|
||||
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
|
||||
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
|
||||
projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true",
|
||||
|
||||
+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));
|
||||
|
||||
@@ -225,6 +225,10 @@ function buildEntryOauthCheck(config) {
|
||||
evidence: {
|
||||
endpoint: "/api/auth/wecom/config",
|
||||
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
|
||||
pcWebLoginEnabled: Boolean(config.wecomPcWebLoginEnabled),
|
||||
pcLoginUrl: missing.length || !config.wecomPcWebLoginEnabled
|
||||
? null
|
||||
: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
|
||||
returnHosts,
|
||||
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
|
||||
|
||||
@@ -38,7 +38,12 @@ function evaluateWecom(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: '企业微信入口与免登配置齐全',
|
||||
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`],
|
||||
checks: [
|
||||
`corpId=${config.wecomCorpId}`,
|
||||
`agentId=${config.wecomAgentId}`,
|
||||
`redirectUri=${config.wecomRedirectUri}`,
|
||||
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户