R85 企微未绑定登录后自动补绑定

这个提交包含在:
2026-05-05 00:05:58 +08:00
父节点 8f08243882
当前提交 d791357ca2
共修改 4 个文件,包含 265 行新增和 0 行删除
+77
查看文件
@@ -11,6 +11,9 @@ const { createNotifications } = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { generators, getOidcClient } = require("../services/oidc");
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
const PENDING_WECOM_BIND_WINDOW_MS = 10 * 60 * 1000;
function getHeaderString(request, name) {
const value = request.headers[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
@@ -118,6 +121,16 @@ function refreshCookieOptions(config) {
};
}
function pendingWecomBindCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: PENDING_WECOM_BIND_WINDOW_MS / 1000,
};
}
function sessionHintCookieOptions(config) {
return {
httpOnly: false,
@@ -272,6 +285,8 @@ async function authRoutes(fastify) {
}
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",
@@ -371,6 +386,7 @@ async function authRoutes(fastify) {
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
let wecomBindingStatus = null;
await fastify.prisma.$transaction(async (tx) => {
await tx.employee.update({
@@ -414,6 +430,63 @@ async function authRoutes(fastify) {
},
});
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,
@@ -431,12 +504,16 @@ async function authRoutes(fastify) {
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) {
reply.clearCookie(PENDING_WECOM_BIND_COOKIE_NAME, pendingWecomBindCookieOptions(fastify.config));
}
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
mustChangePassword: Boolean(employee.must_change_password),
wecomBindingStatus,
});
});
+22
查看文件
@@ -8,6 +8,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
const { sendData } = require("../utils/reply");
const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
const DATE_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_RETURN_TO = "/portal";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
@@ -37,6 +38,16 @@ function bindCookieOptions(config) {
};
}
function pendingLoginBindCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: DATE_WINDOW_MS / 1000,
};
}
function resolvePrimaryAppOrigin(config, request) {
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
const first = configured
@@ -481,6 +492,17 @@ async function wecomAuthRoutes(fastify) {
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),
);
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`);
}