文件
hyb-oa/server/src/routes/wecomAuth.js
T
wangsli 9b12451b85
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
实现企微首次免密自动绑定(07-28-all-account-login-readiness)
2026-07-28 11:18:50 +08:00

679 行
24 KiB
JavaScript

const crypto = require("node:crypto");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
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 {
WecomLoginAutoBindingError,
autoBindExistingWecomEmployee,
fetchWecomLoginIdentity,
} = require("../services/integrations/wecomLoginAutoBinding");
const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
const DATE_WINDOW_MS = 10 * 60 * 1000;
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", "onboard"]).optional(),
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
});
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
function refreshCookieOptions(config) {
return {
httpOnly: true,
sameSite: "strict",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: config.refreshTtlSeconds,
};
}
function sessionHintCookieOptions(config) {
return {
httpOnly: false,
sameSite: "strict",
secure: config.cookieSecure,
path: "/",
maxAge: config.refreshTtlSeconds,
};
}
function bindCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth/wecom",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function pendingLoginBindCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function deviceFingerprintCookieOptions(config) {
return {
httpOnly: false,
sameSite: "lax",
secure: config.cookieSecure,
path: "/",
maxAge: config.refreshTtlSeconds,
};
}
function resolvePrimaryAppOrigin(config, request) {
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
const first = configured
.split(",")
.map((s) => s.trim())
.filter(Boolean)[0];
if (first) return first;
const host = request.headers.host ?? "127.0.0.1:3000";
const protocol = request.protocol ?? "http";
return `${protocol}://${host}`;
}
function parseAllowedReturnHosts(config, request) {
const hosts = new Set();
const appOrigins = String(config.appOrigin || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
for (const item of appOrigins) {
try {
hosts.add(new URL(item).host);
} catch {
}
}
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
hosts.add(item);
}
if (!hosts.size) {
try {
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
} catch {
}
}
return hosts;
}
function sanitizeReturnTo(rawValue, config, request) {
if (typeof rawValue !== "string") {
return DEFAULT_RETURN_TO;
}
const trimmed = rawValue.trim();
if (!trimmed) {
return DEFAULT_RETURN_TO;
}
if (trimmed.startsWith("/")) {
return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed;
}
try {
const url = new URL(trimmed);
const allowedHosts = parseAllowedReturnHosts(config, request);
if (!allowedHosts.has(url.host)) {
return DEFAULT_RETURN_TO;
}
return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`;
} catch {
return DEFAULT_RETURN_TO;
}
}
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 onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
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,
onboardingEnabled,
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
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 (!entryConfig.bindEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
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 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" || mode === "onboard") {
params.set("mode", mode);
}
return `/api/auth/wecom/login?${params.toString()}`;
}
async function getWecomAccessToken(config) {
if (!config.wecomAgentSecret) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { wecom: "missing_agent_secret" },
});
}
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
url.searchParams.set("corpid", config.wecomCorpId);
url.searchParams.set("corpsecret", config.wecomAgentSecret);
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" },
});
}
return String(payload.access_token);
}
async function exchangeWecomCode(config, code) {
const accessToken = await getWecomAccessToken(config);
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
url.searchParams.set("access_token", accessToken);
url.searchParams.set("code", code);
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" },
});
}
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
const hasStringValue = (value) => typeof value === "string" && value.trim().length > 0;
const pickCandidate = (...values) => {
for (const value of values) {
if (hasStringValue(value)) {
return value.trim();
}
}
return null;
};
return {
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,
payloadKeys,
fieldPresence: {
UserId: hasStringValue(payload.UserId),
userid: hasStringValue(payload.userid),
OpenId: hasStringValue(payload.OpenId),
openid: hasStringValue(payload.openid),
DeviceId: hasStringValue(payload.DeviceId),
deviceid: hasStringValue(payload.deviceid),
external_userid: hasStringValue(payload.external_userid),
user_ticket: hasStringValue(payload.user_ticket),
corpid: hasStringValue(payload.corpid),
},
},
};
}
function buildWecomAuthorizeUrl(config, state, mode = "login") {
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
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`;
}
function resolveOAuthDeviceFingerprint(value) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
return `wecom:${crypto.randomUUID()}`;
}
async function createAuthenticatedSession({ prisma, request, config, employeeId, tokenVersion, deviceFingerprint, auditAction, extra = {} }) {
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + config.refreshTtlSeconds * 1000);
await prisma.$transaction(async (tx) => {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employeeId,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({ where: { id: { in: idsToRevoke } }, data: { revoked_at: now } });
}
await tx.authSession.create({
data: {
employee_id: employeeId,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: String(request.headers["user-agent"] ?? ""),
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employeeId,
action: auditAction,
targetType: "employee",
targetId: employeeId,
extra,
}),
});
});
return {
refreshToken,
accessToken: request.server.issueAccessToken({ id: employeeId, token_version: tokenVersion }),
};
}
async function wecomAuthRoutes(fastify) {
fastify.get("/wecom/config", async (request, reply) => {
return sendData(reply, request, resolveWecomEntryConfig(fastify.config));
});
fastify.get("/wecom/login", async (request, reply) => {
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 if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config);
} else {
assertWecomEntryReady(fastify.config);
}
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
const state = signPayload({
exp: Date.now() + DATE_WINDOW_MS,
nonce: crypto.randomUUID(),
returnTo,
mode,
deviceFingerprint,
}, fastify.config.jwtSecret);
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
});
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
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() });
}
const returnTo = sanitizeReturnTo(parsed.data?.returnTo, fastify.config, request);
const token = signPayload({
exp: Date.now() + DATE_WINDOW_MS,
employeeId: request.employee.id,
returnTo,
}, fastify.config.jwtSecret);
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
return sendData(reply, request, {
loginUrl: buildWecomLoginUrl(returnTo, "bind"),
});
});
fastify.post("/wecom/unbind", { preHandler: fastify.authenticate }, async (request, reply) => {
await fastify.prisma.wecomIdentityBinding.deleteMany({ where: { employee_id: request.employee.id } });
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "AUTH_WECOM_UNBIND",
targetType: "employee",
targetId: request.employee.id,
extra: {},
}),
});
} catch (error) {
request.log.error({ err: error }, "wecom unbind audit failed");
}
return sendData(reply, request, { ok: true });
});
fastify.get("/wecom/callback", async (request, reply) => {
assertWecomOauthReady(fastify.config);
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) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const ctx = verified.payload ?? {};
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" : ctx.mode === "onboard" ? "onboard" : "login";
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
const profile = await exchangeWecomCode(fastify.config, code);
if (!profile.externalUserId) {
request.log.warn(
{
requestId: request.id,
mode,
hasUserId: false,
hasOpenId: Boolean(profile.externalOpenUserId),
hasDeviceId: Boolean(profile.deviceId),
wecomErrCode: profile.diagnostics?.errcode ?? null,
wecomErrMsg: profile.diagnostics?.errmsg ?? null,
payloadKeys: profile.diagnostics?.payloadKeys ?? [],
fieldPresence: profile.diagnostics?.fieldPresence ?? {},
},
"wecom callback missing internal user id",
);
throw new AppError({
code: "AUTH_003",
message: "企业微信认证失败",
statusCode: 401,
details: {
wecom: "missing_user_id",
hasOpenId: Boolean(profile.externalOpenUserId),
mode,
},
});
}
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);
if (!bindVerified.ok) {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}?wecomBind=expired`);
}
const bindPayload = bindVerified.payload ?? {};
if (typeof bindPayload.employeeId !== "string") {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}?wecomBind=invalid`);
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: bindPayload.employeeId },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}?wecomBind=employee_inactive`);
}
const conflicting = await fastify.prisma.wecomIdentityBinding.findFirst({
where: { external_user_id: profile.externalUserId, NOT: { employee_id: employee.id } },
select: { employee_id: true },
});
if (conflicting) {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}?wecomBind=conflict`);
}
await fastify.prisma.wecomIdentityBinding.upsert({
where: { employee_id: employee.id },
update: {
external_user_id: profile.externalUserId,
external_open_user_id: profile.externalOpenUserId,
updated_by: employee.id,
},
create: {
employee_id: employee.id,
external_user_id: profile.externalUserId,
external_open_user_id: profile.externalOpenUserId,
created_by: employee.id,
updated_by: employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_WECOM_BIND",
targetType: "employee",
targetId: employee.id,
extra: { externalUserId: profile.externalUserId },
}),
});
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}?wecomBind=success`);
}
const binding = await fastify.prisma.wecomIdentityBinding.findUnique({
where: { external_user_id: profile.externalUserId },
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
});
let loginEmployee = binding?.employee ?? null;
let bindingSource = "existing";
if (loginEmployee && loginEmployee.status !== "active") {
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
return reply.redirect(
`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=account_mapping_inactive`,
);
}
if (!loginEmployee) {
try {
const identity = await fetchWecomLoginIdentity({
accessToken: profile.accessToken,
externalUserId: profile.externalUserId,
});
loginEmployee = await autoBindExistingWecomEmployee({
prisma: fastify.prisma,
request,
identity,
externalOpenUserId: profile.externalOpenUserId,
});
bindingSource = loginEmployee.autoBound ? "auto" : "concurrent_existing";
} catch (error) {
if (!(error instanceof WecomLoginAutoBindingError)) throw error;
const conflictCodes = new Set([
"oa_employee_conflict",
"wecom_userid_conflict",
"oa_employee_binding_conflict",
"binding_race_conflict",
"wecom_identity_mismatch",
]);
const inactiveCodes = new Set(["wecom_member_inactive", "oa_employee_inactive"]);
const notice = conflictCodes.has(error.code)
? "account_mapping_conflict"
: inactiveCodes.has(error.code)
? "account_mapping_inactive"
: "account_mapping_required";
request.log.warn(
{ requestId: request.id, errorCode: error.code, notice },
"wecom automatic login binding failed closed",
);
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
return reply.redirect(
`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${notice}`,
);
}
}
const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint);
const session = await createAuthenticatedSession({
prisma: fastify.prisma,
request,
config: fastify.config,
employeeId: loginEmployee.id,
tokenVersion: loginEmployee.token_version,
deviceFingerprint,
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { bindingSource },
});
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
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}`);
});
}
module.exports = wecomAuthRoutes;