实现企微首次免密自动绑定(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));