R85 企微未绑定登录后自动补绑定
这个提交包含在:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户