实现企微首次免密自动绑定(07-28-all-account-login-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-28 11:18:50 +08:00
父节点 0551212b78
当前提交 9b12451b85
共修改 22 个文件,包含 1764 行新增和 132 行删除
+1 -59
查看文件
@@ -628,7 +628,6 @@ async function authRoutes(fastify, options = {}) {
const now = new Date();
const pendingWecomBindToken = request.cookies?.[PENDING_WECOM_BIND_COOKIE_NAME];
const pendingWecomBind = verifyToken(pendingWecomBindToken, fastify.config.jwtSecret);
if (employee.login_locked_until && employee.login_locked_until > now) {
throw new AppError({
code: "AUTH_002",
@@ -728,7 +727,7 @@ async function authRoutes(fastify, options = {}) {
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
let wecomBindingStatus = null;
const wecomBindingStatus = null;
await fastify.prisma.$transaction(async (tx) => {
const guardedEmployee = await tx.employee.updateMany({
@@ -765,63 +764,6 @@ async function authRoutes(fastify, options = {}) {
expiresAt,
});
if (pendingWecomBind.ok) {
const payload = pendingWecomBind.payload ?? {};
const externalUserId =
typeof payload.externalUserId === "string" && payload.externalUserId.trim()
? payload.externalUserId.trim()
: null;
const externalOpenUserId =
typeof payload.externalOpenUserId === "string" && payload.externalOpenUserId.trim()
? payload.externalOpenUserId.trim()
: null;
const isExpired = typeof payload.exp !== "number" || payload.exp <= Date.now();
if (!externalUserId || isExpired) {
wecomBindingStatus = "invalid";
} else {
const conflictingBinding = await tx.wecomIdentityBinding.findFirst({
where: {
external_user_id: externalUserId,
NOT: { employee_id: employee.id },
},
select: { employee_id: true },
});
if (conflictingBinding) {
wecomBindingStatus = "conflict";
} else {
await tx.wecomIdentityBinding.upsert({
where: { employee_id: employee.id },
update: {
external_user_id: externalUserId,
external_open_user_id: externalOpenUserId,
updated_by: employee.id,
},
create: {
employee_id: employee.id,
external_user_id: externalUserId,
external_open_user_id: externalOpenUserId,
created_by: employee.id,
updated_by: employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_WECOM_BIND",
targetType: "employee",
targetId: employee.id,
extra: { externalUserId },
}),
});
wecomBindingStatus = "bound";
}
}
} else if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) {
wecomBindingStatus = "invalid";
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
+53 -15
查看文件
@@ -11,6 +11,11 @@ const {
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";
@@ -603,19 +608,51 @@ async function wecomAuthRoutes(fastify) {
where: { external_user_id: profile.externalUserId },
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
});
if (!binding?.employee || binding.employee.status !== "active") {
const pendingBindToken = signPayload({
exp: Date.now() + DATE_WINDOW_MS,
externalUserId: profile.externalUserId,
externalOpenUserId: profile.externalOpenUserId,
returnTo,
}, fastify.config.jwtSecret);
reply.setCookie(
PENDING_LOGIN_BIND_COOKIE_NAME,
pendingBindToken,
pendingLoginBindCookieOptions(fastify.config),
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`,
);
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`);
}
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);
@@ -623,13 +660,14 @@ async function wecomAuthRoutes(fastify) {
prisma: fastify.prisma,
request,
config: fastify.config,
employeeId: binding.employee.id,
tokenVersion: binding.employee.token_version,
employeeId: loginEmployee.id,
tokenVersion: loginEmployee.token_version,
deviceFingerprint,
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
extra: { externalUserId: profile.externalUserId },
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));
@@ -0,0 +1,473 @@
#!/usr/bin/env node
const crypto = require("node:crypto");
const { createPrismaClient } = require("../prismaClient");
const { hashPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const PROTECTED_ADMIN_ROLES = ["admin", "system_admin"];
const APPLY_CONFIRMATION = "ACTIVE_ADMIN_BREAK_GLASS";
function writeResult(result) {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
async function readPasswordFromStdin() {
if (process.stdin.isTTY) {
throw new Error("password_stdin_required");
}
process.stdin.setEncoding("utf8");
let input = "";
for await (const chunk of process.stdin) {
input += chunk;
if (input.length > 1024) {
throw new Error("password_input_too_long");
}
}
const password = input.replace(/[\r\n]+$/, "");
if (!password || password !== password.trim()) {
throw new Error("password_input_invalid");
}
const errors = validatePassword(password);
if (errors.length > 0) {
throw new Error("password_policy_failed");
}
return password;
}
async function findActiveAdmins(prisma) {
return prisma.employee.findMany({
where: {
status: "active",
archived_at: null,
roles: { some: { role_code: { in: PROTECTED_ADMIN_ROLES } } },
},
select: { id: true, token_version: true },
take: 2,
});
}
async function preflight(prisma) {
const admins = await findActiveAdmins(prisma);
return {
status: admins.length === 1 ? "PASS" : "BLOCKED",
activeAdminMatches: admins.length,
exactTarget: admins.length === 1,
};
}
async function verifyState(prisma) {
const admins = await findActiveAdmins(prisma);
if (admins.length !== 1) {
return {
status: "BLOCKED",
activeAdminMatches: admins.length,
exactTarget: false,
};
}
const target = admins[0];
const [
employee,
activeSessions,
activePasswordResetCodes,
activeEmailLoginCodes,
auditEntries,
] = await Promise.all([
prisma.employee.findUnique({
where: { id: target.id },
select: {
must_change_password: true,
login_failed_count: true,
login_locked_until: true,
email_login_code_failed_count: true,
email_login_code_locked_until: true,
password_reset_failed_count: true,
password_reset_locked_until: true,
token_version: true,
},
}),
prisma.authSession.count({
where: { employee_id: target.id, revoked_at: null },
}),
prisma.passwordResetCode.count({
where: { employee_id: target.id, invalidated_at: null },
}),
prisma.emailLoginCode.count({
where: { employee_id: target.id, used_at: null },
}),
prisma.auditLog.count({
where: {
actor_id: target.id,
target_id: target.id,
action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET",
},
}),
]);
const locksCleared = Boolean(
employee &&
employee.login_failed_count === 0 &&
employee.login_locked_until === null &&
employee.email_login_code_failed_count === 0 &&
employee.email_login_code_locked_until === null &&
employee.password_reset_failed_count === 0 &&
employee.password_reset_locked_until === null,
);
const pass = Boolean(
employee?.must_change_password === true &&
locksCleared &&
activeSessions === 0 &&
activePasswordResetCodes === 0 &&
activeEmailLoginCodes === 0 &&
auditEntries > 0,
);
return {
status: pass ? "PASS" : "BLOCKED",
activeAdminMatches: 1,
exactTarget: true,
mustChangePassword: employee?.must_change_password === true,
locksCleared,
activeSessions,
activePasswordResetCodes,
activeEmailLoginCodes,
auditPresent: auditEntries > 0,
};
}
async function verifyCompletedPasswordChange(prisma) {
const admins = await findActiveAdmins(prisma);
if (admins.length !== 1) {
return {
status: "BLOCKED",
activeAdminMatches: admins.length,
exactTarget: false,
};
}
const target = admins[0];
const [employee, activeSessions, passwordChangeAudits, breakGlassAudits] =
await Promise.all([
prisma.employee.findUnique({
where: { id: target.id },
select: {
must_change_password: true,
login_failed_count: true,
login_locked_until: true,
email_login_code_failed_count: true,
email_login_code_locked_until: true,
password_reset_failed_count: true,
password_reset_locked_until: true,
},
}),
prisma.authSession.count({
where: { employee_id: target.id, revoked_at: null },
}),
prisma.auditLog.count({
where: {
actor_id: target.id,
target_id: target.id,
action: "AUTH_PASSWORD_CHANGED",
},
}),
prisma.auditLog.count({
where: {
actor_id: target.id,
target_id: target.id,
action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET",
},
}),
]);
const locksCleared = Boolean(
employee &&
employee.login_failed_count === 0 &&
employee.login_locked_until === null &&
employee.email_login_code_failed_count === 0 &&
employee.email_login_code_locked_until === null &&
employee.password_reset_failed_count === 0 &&
employee.password_reset_locked_until === null,
);
const pass = Boolean(
employee?.must_change_password === false &&
locksCleared &&
activeSessions === 1 &&
passwordChangeAudits > 0 &&
breakGlassAudits > 0,
);
return {
status: pass ? "PASS" : "BLOCKED",
activeAdminMatches: 1,
exactTarget: true,
mustChangePassword: employee?.must_change_password === true,
locksCleared,
activeSessions,
passwordChangeAuditPresent: passwordChangeAudits > 0,
breakGlassAuditPresent: breakGlassAudits > 0,
};
}
async function diagnoseWecomBinding(prisma, now = new Date()) {
const admins = await findActiveAdmins(prisma);
if (admins.length !== 1) {
return {
status: "BLOCKED",
activeAdminMatches: admins.length,
exactTarget: false,
};
}
const target = admins[0];
const since = new Date(now.getTime() - 30 * 60 * 1000);
const [binding, recentAudits, latestDirectorySync, linkedHrSyncItems] =
await Promise.all([
prisma.wecomIdentityBinding.findUnique({
where: { employee_id: target.id },
select: { employee_id: true },
}),
prisma.auditLog.findMany({
where: {
actor_id: target.id,
created_at: { gte: since },
action: {
in: [
"AUTH_LOGIN_SUCCESS",
"AUTH_LOGIN_FAILED",
"AUTH_LOGIN_LOCKED",
"AUTH_WECOM_BIND",
"AUTH_WECOM_LOGIN_SUCCESS",
],
},
},
select: { action: true, user_agent: true },
}),
prisma.wecomDirectorySyncRun.findFirst({
where: { employee_id: target.id, external_user_id: { not: null } },
orderBy: { created_at: "desc" },
select: { external_user_id: true, status: true },
}),
prisma.wecomHrMasterSyncItem.count({
where: { employee_id: target.id, status: "success" },
}),
]);
const directoryUserIdConflicts = latestDirectorySync?.external_user_id
? await prisma.wecomIdentityBinding.count({
where: {
external_user_id: latestDirectorySync.external_user_id,
NOT: { employee_id: target.id },
},
})
: 0;
const passwordLogins = recentAudits.filter(
(audit) => audit.action === "AUTH_LOGIN_SUCCESS",
);
const wecomWebViewPasswordLogins = passwordLogins.filter((audit) =>
/wxwork|micromessenger/i.test(audit.user_agent ?? ""),
);
const failedPasswordLogins = recentAudits.filter((audit) =>
["AUTH_LOGIN_FAILED", "AUTH_LOGIN_LOCKED"].includes(audit.action),
);
const failedWecomWebViewPasswordLogins = failedPasswordLogins.filter(
(audit) => /wxwork|micromessenger/i.test(audit.user_agent ?? ""),
);
return {
status: "PASS",
activeAdminMatches: 1,
exactTarget: true,
targetBound: Boolean(binding),
directoryUserIdPresent: Boolean(latestDirectorySync?.external_user_id),
latestDirectorySyncSuccessful: latestDirectorySync?.status === "success",
directoryUserIdAlreadyBoundElsewhere: directoryUserIdConflicts > 0,
linkedSuccessfulHrSyncPresent: linkedHrSyncItems > 0,
recentPasswordLogins: passwordLogins.length,
recentPasswordLoginsFromWecomWebView: wecomWebViewPasswordLogins.length,
recentFailedPasswordLogins: failedPasswordLogins.length,
recentFailedPasswordLoginsFromWecomWebView:
failedWecomWebViewPasswordLogins.length,
recentWecomBindAudits: recentAudits.filter(
(audit) => audit.action === "AUTH_WECOM_BIND",
).length,
recentWecomLoginAudits: recentAudits.filter(
(audit) => audit.action === "AUTH_WECOM_LOGIN_SUCCESS",
).length,
};
}
async function applyReset(prisma, password) {
const admins = await findActiveAdmins(prisma);
if (admins.length !== 1) {
throw new Error("active_admin_target_not_unique");
}
const target = admins[0];
const passwordHash = await hashPassword(password);
const now = new Date();
const requestId = `ops-break-glass-${crypto.randomUUID()}`;
const result = await prisma.$transaction(async (tx) => {
const current = await tx.employee.findUnique({
where: { id: target.id },
select: {
status: true,
archived_at: true,
token_version: true,
roles: { select: { role_code: true } },
},
});
const protectedRoleCount =
current?.roles.filter((role) =>
PROTECTED_ADMIN_ROLES.includes(role.role_code),
).length ?? 0;
if (
!current ||
current.status !== "active" ||
current.archived_at !== null ||
protectedRoleCount === 0 ||
current.token_version !== target.token_version
) {
throw new Error("active_admin_target_changed");
}
const employee = await tx.employee.update({
where: { id: target.id },
data: {
password_hash: passwordHash,
must_change_password: true,
login_failed_count: 0,
login_failed_first_at: null,
login_locked_until: null,
email_login_code_failed_count: 0,
email_login_code_failed_first_at: null,
email_login_code_locked_until: null,
password_reset_failed_count: 0,
password_reset_failed_first_at: null,
password_reset_locked_until: null,
password_reset_request_count: 0,
password_reset_request_first_at: null,
password_reset_last_requested_at: null,
token_version: { increment: 1 },
updated_by: target.id,
},
select: { must_change_password: true, token_version: true },
});
const revokedSessions = await tx.authSession.updateMany({
where: { employee_id: target.id, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
const invalidatedPasswordResetCodes = await tx.passwordResetCode.updateMany(
{
where: { employee_id: target.id, invalidated_at: null },
data: { invalidated_at: now },
},
);
const consumedEmailLoginCodes = await tx.emailLoginCode.updateMany({
where: { employee_id: target.id, used_at: null },
data: { used_at: now },
});
await tx.auditLog.create({
data: {
request_id: requestId,
actor_id: target.id,
action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET",
target_type: "employee",
target_id: target.id,
ip_address: null,
user_agent: "controlled-ops-script",
extra: { credentialRecorded: false },
},
});
return {
status: "PASS",
activeAdminMatches: 1,
passwordUpdated: true,
mustChangePassword: employee.must_change_password === true,
tokenVersionIncremented:
employee.token_version === target.token_version + 1,
locksCleared: true,
revokedSessions: revokedSessions.count,
invalidatedPasswordResetCodes: invalidatedPasswordResetCodes.count,
consumedEmailLoginCodes: consumedEmailLoginCodes.count,
auditCreated: true,
};
});
return result;
}
async function main() {
const { prisma, pool } = createPrismaClient();
try {
if (process.argv.includes("--preflight")) {
writeResult(await preflight(prisma));
return;
}
if (process.argv.includes("--verify")) {
writeResult(await verifyState(prisma));
return;
}
if (process.argv.includes("--verify-password-changed")) {
writeResult(await verifyCompletedPasswordChange(prisma));
return;
}
if (process.argv.includes("--diagnose-wecom-binding")) {
writeResult(await diagnoseWecomBinding(prisma));
return;
}
const applyIndex = process.argv.indexOf("--apply");
if (applyIndex < 0 || process.argv[applyIndex + 1] !== APPLY_CONFIRMATION) {
throw new Error("explicit_confirmation_required");
}
const password = await readPasswordFromStdin();
writeResult(await applyReset(prisma, password));
} finally {
await prisma.$disconnect();
await pool.end();
}
}
if (require.main === module) {
main().catch((error) => {
const safeErrorCategory =
typeof error?.message === "string" &&
[
"password_stdin_required",
"password_input_too_long",
"password_input_invalid",
"password_policy_failed",
"active_admin_target_not_unique",
"active_admin_target_changed",
"explicit_confirmation_required",
].includes(error.message)
? error.message
: typeof error?.code === "string"
? error.code
: typeof error?.name === "string"
? error.name
: "runtime_error";
writeResult({
status: "BLOCKED",
error: "break_glass_operation_failed",
category: safeErrorCategory,
});
process.exitCode = 1;
});
}
module.exports = {
applyReset,
diagnoseWecomBinding,
findActiveAdmins,
preflight,
readPasswordFromStdin,
verifyCompletedPasswordChange,
verifyState,
};
@@ -0,0 +1,257 @@
const { z } = require("zod");
const { buildAuditLogData } = require("../../utils/audit");
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
const EMAIL_SCHEMA = z
.string()
.trim()
.email()
.max(200)
.transform((value) => value.toLowerCase());
class WecomLoginAutoBindingError extends Error {
constructor(code, message) {
super(message);
this.name = "WecomLoginAutoBindingError";
this.code = code;
}
}
function requiredString(value, maxLength) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function normalizeEmail(value) {
const parsed = EMAIL_SCHEMA.safeParse(value);
return parsed.success ? parsed.data : "";
}
async function fetchWecomLoginIdentity({
accessToken,
externalUserId,
fetchImpl = fetch,
timeoutMs = 5000,
}) {
const token = requiredString(accessToken, 2000);
const userid = requiredString(externalUserId, 128);
if (!token || !userid) {
throw new WecomLoginAutoBindingError(
"wecom_identity_invalid",
"WeCom login identity is invalid",
);
}
const url = new URL(`${WECOM_API_BASE}/cgi-bin/user/get`);
url.searchParams.set("access_token", token);
url.searchParams.set("userid", userid);
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(100, Number(timeoutMs) || 5000),
);
let response;
try {
response = await fetchImpl(url, {
method: "GET",
signal: controller.signal,
});
} catch {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
} finally {
clearTimeout(timer);
}
if (!response || typeof response.json !== "function") {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
}
const returnedUserid = requiredString(payload.userid ?? payload.UserId, 128);
if (!returnedUserid || returnedUserid !== userid) {
throw new WecomLoginAutoBindingError(
"wecom_identity_mismatch",
"WeCom member identity does not match OAuth identity",
);
}
const status = Number(payload.status ?? payload.enable ?? 0);
if (status !== 1) {
throw new WecomLoginAutoBindingError(
"wecom_member_inactive",
"WeCom member is inactive",
);
}
const email =
normalizeEmail(payload.biz_mail) || normalizeEmail(payload.email);
return { externalUserId: userid, email: email || null };
}
async function resolveUniqueEmployee(tx, identity) {
const matches = await tx.employee.findMany({
where: {
OR: [
{ employee_no: identity.externalUserId },
...(identity.email
? [{ email: { equals: identity.email, mode: "insensitive" } }]
: []),
],
},
select: {
id: true,
employee_no: true,
email: true,
status: true,
archived_at: true,
token_version: true,
},
take: 3,
});
const uniqueMatches = new Map(
matches.map((employee) => [employee.id, employee]),
);
if (uniqueMatches.size === 0) {
throw new WecomLoginAutoBindingError(
"oa_employee_not_found",
"No OA employee matches the WeCom identity",
);
}
if (uniqueMatches.size !== 1) {
throw new WecomLoginAutoBindingError(
"oa_employee_conflict",
"WeCom identity matches multiple OA employees",
);
}
const employee = [...uniqueMatches.values()][0];
if (employee.status !== "active" || employee.archived_at !== null) {
throw new WecomLoginAutoBindingError(
"oa_employee_inactive",
"Matched OA employee is inactive",
);
}
const matchByUserid = employee.employee_no === identity.externalUserId;
const matchByEmail = Boolean(
identity.email && employee.email?.toLowerCase() === identity.email,
);
if (!matchByUserid && !matchByEmail) {
throw new WecomLoginAutoBindingError(
"oa_employee_not_found",
"No OA employee matches the WeCom identity",
);
}
return {
employee,
matchBy:
matchByUserid && matchByEmail
? "userid_and_email"
: matchByUserid
? "userid"
: "email",
};
}
async function autoBindExistingWecomEmployee({
prisma,
request,
identity,
externalOpenUserId = null,
}) {
const externalUserId = requiredString(identity?.externalUserId, 128);
const email = normalizeEmail(identity?.email);
if (!externalUserId) {
throw new WecomLoginAutoBindingError(
"wecom_identity_invalid",
"WeCom login identity is invalid",
);
}
try {
return await prisma.$transaction(async (tx) => {
const { employee, matchBy } = await resolveUniqueEmployee(tx, {
externalUserId,
email,
});
const [useridBinding, employeeBinding] = await Promise.all([
tx.wecomIdentityBinding.findUnique({
where: { external_user_id: externalUserId },
select: { employee_id: true },
}),
tx.wecomIdentityBinding.findUnique({
where: { employee_id: employee.id },
select: { external_user_id: true },
}),
]);
if (useridBinding && useridBinding.employee_id !== employee.id) {
throw new WecomLoginAutoBindingError(
"wecom_userid_conflict",
"WeCom userid is already bound to another OA employee",
);
}
if (
employeeBinding &&
employeeBinding.external_user_id !== externalUserId
) {
throw new WecomLoginAutoBindingError(
"oa_employee_binding_conflict",
"OA employee is already bound to another WeCom userid",
);
}
if (!useridBinding && !employeeBinding) {
await tx.wecomIdentityBinding.create({
data: {
employee_id: employee.id,
external_user_id: externalUserId,
external_open_user_id:
requiredString(externalOpenUserId, 128) || null,
created_by: employee.id,
updated_by: employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_WECOM_AUTO_BOUND",
targetType: "employee",
targetId: employee.id,
extra: { matchBy, emailMatched: matchBy.includes("email") },
}),
});
}
return {
id: employee.id,
status: employee.status,
token_version: employee.token_version,
autoBound: !useridBinding && !employeeBinding,
};
});
} catch (error) {
if (error instanceof WecomLoginAutoBindingError) throw error;
if (error?.code === "P2002") {
throw new WecomLoginAutoBindingError(
"binding_race_conflict",
"WeCom binding changed concurrently",
);
}
throw error;
}
}
module.exports = {
WecomLoginAutoBindingError,
autoBindExistingWecomEmployee,
fetchWecomLoginIdentity,
};