679 行
24 KiB
JavaScript
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;
|