实现企微首次免密自动绑定(07-28-all-account-login-readiness)
这个提交包含在:
+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,
|
||||
};
|
||||
@@ -370,7 +370,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-binds pending wecom identity after password login", async () => {
|
||||
it("does not use password login to bind a pending wecom identity", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||
const pendingBindToken = signPayload({
|
||||
@@ -395,38 +395,18 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(wecomFindFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
external_user_id: "wecom-user-1",
|
||||
NOT: { employee_id: EMPLOYEE_ID },
|
||||
},
|
||||
select: { employee_id: true },
|
||||
});
|
||||
expect(wecomUpsert).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID },
|
||||
update: {
|
||||
external_user_id: "wecom-user-1",
|
||||
external_open_user_id: "openid-1",
|
||||
updated_by: EMPLOYEE_ID,
|
||||
},
|
||||
create: {
|
||||
employee_id: EMPLOYEE_ID,
|
||||
external_user_id: "wecom-user-1",
|
||||
external_open_user_id: "openid-1",
|
||||
created_by: EMPLOYEE_ID,
|
||||
updated_by: EMPLOYEE_ID,
|
||||
},
|
||||
});
|
||||
expect(wecomFindFirst).not.toHaveBeenCalled();
|
||||
expect(wecomUpsert).not.toHaveBeenCalled();
|
||||
expect(response.json().data).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "access-token",
|
||||
mustChangePassword: false,
|
||||
wecomBindingStatus: "bound",
|
||||
wecomBindingStatus: null,
|
||||
}),
|
||||
);
|
||||
expect(response.headers["set-cookie"]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("wecom_login_bind="),
|
||||
expect.stringMatching(/wecom_login_bind=;/),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ describe("wecom auth routes R27", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("redirects unbound users back to login with wecom hint", async () => {
|
||||
it("fails closed without a password-binding cookie when the WeCom profile is unavailable", async () => {
|
||||
mockWecomFetch({ userId: "wecom-unbound" });
|
||||
const prisma = buildPrisma({
|
||||
wecomIdentityBinding: {
|
||||
@@ -439,13 +439,15 @@ describe("wecom auth routes R27", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
|
||||
expect(response.headers.location).toBe(
|
||||
"https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required",
|
||||
);
|
||||
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts lowercase userid and redirects unbound users back to login", async () => {
|
||||
it("accepts lowercase userid and fails closed when no directory profile is available", async () => {
|
||||
mockWecomFetchWithPayload({
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
@@ -470,8 +472,10 @@ describe("wecom auth routes R27", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
|
||||
expect(response.headers.location).toBe(
|
||||
"https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required",
|
||||
);
|
||||
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
|
||||
expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
|
||||
where: { external_user_id: "wecom-unbound-lowercase" },
|
||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||
@@ -480,6 +484,93 @@ describe("wecom auth routes R27", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("auto-binds a unique active OA employee by enterprise email and logs in without a password", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "access-token" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, UserId: "wecom-auto-user", OpenId: "open-auto-user" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errcode: 0,
|
||||
userid: "wecom-auto-user",
|
||||
status: 1,
|
||||
biz_mail: "[email protected]",
|
||||
}),
|
||||
});
|
||||
const authSession = {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue({ id: "session-1" }),
|
||||
};
|
||||
const prisma = buildPrisma({
|
||||
authSession,
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue([{
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E0001",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 0,
|
||||
}]),
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null),
|
||||
create: vi.fn().mockResolvedValue({ id: "binding-auto" }),
|
||||
},
|
||||
});
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
const app = await buildApp({ prisma });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "nonce-auto-bind",
|
||||
returnTo: "/mobile",
|
||||
mode: "login",
|
||||
deviceFingerprint: "auto-bind-device",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("https://oa.hyinda.com/mobile");
|
||||
expect(prisma.wecomIdentityBinding.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
external_user_id: "wecom-auto-user",
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_AUTO_BOUND",
|
||||
extra: { matchBy: "email", emailMatched: true },
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource: "auto" },
|
||||
}),
|
||||
});
|
||||
expect(authSession.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "auto-bind-device" }),
|
||||
});
|
||||
expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("sets session hint cookie after successful wecom login callback", async () => {
|
||||
mockWecomFetch({ userId: "wecom-bound-user" });
|
||||
const authSession = {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const {
|
||||
applyReset,
|
||||
diagnoseWecomBinding,
|
||||
preflight,
|
||||
verifyCompletedPasswordChange,
|
||||
verifyState,
|
||||
} = require("../../../src/scripts/reset-active-admin-password");
|
||||
|
||||
function createPrismaFixture({ adminCount = 1 } = {}) {
|
||||
const admins = Array.from({ length: adminCount }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
token_version: 7,
|
||||
}));
|
||||
const tx = {
|
||||
employee: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 7,
|
||||
roles: [{ role_code: "system_admin" }],
|
||||
}),
|
||||
update: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ must_change_password: true, token_version: 8 }),
|
||||
},
|
||||
authSession: { updateMany: vi.fn().mockResolvedValue({ count: 2 }) },
|
||||
passwordResetCode: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
||||
emailLoginCode: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
return {
|
||||
tx,
|
||||
prisma: {
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue(admins),
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
must_change_password: true,
|
||||
login_failed_count: 0,
|
||||
login_locked_until: null,
|
||||
email_login_code_failed_count: 0,
|
||||
email_login_code_locked_until: null,
|
||||
password_reset_failed_count: 0,
|
||||
password_reset_locked_until: null,
|
||||
token_version: 8,
|
||||
}),
|
||||
},
|
||||
authSession: { count: vi.fn().mockResolvedValue(0) },
|
||||
passwordResetCode: { count: vi.fn().mockResolvedValue(0) },
|
||||
emailLoginCode: { count: vi.fn().mockResolvedValue(0) },
|
||||
auditLog: { count: vi.fn().mockResolvedValue(1) },
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
wecomDirectorySyncRun: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
wecomHrMasterSyncItem: { count: vi.fn().mockResolvedValue(0) },
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("active administrator break-glass reset", () => {
|
||||
it("blocks preflight unless the active protected administrator is unique", async () => {
|
||||
const none = createPrismaFixture({ adminCount: 0 });
|
||||
const multiple = createPrismaFixture({ adminCount: 2 });
|
||||
|
||||
await expect(preflight(none.prisma)).resolves.toEqual({
|
||||
status: "BLOCKED",
|
||||
activeAdminMatches: 0,
|
||||
exactTarget: false,
|
||||
});
|
||||
await expect(preflight(multiple.prisma)).resolves.toEqual({
|
||||
status: "BLOCKED",
|
||||
activeAdminMatches: 2,
|
||||
exactTarget: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets only the unique active administrator and revokes authentication state", async () => {
|
||||
const { prisma, tx } = createPrismaFixture();
|
||||
|
||||
const result = await applyReset(prisma, "UniqueBreakGlass9Qz!");
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "PASS",
|
||||
activeAdminMatches: 1,
|
||||
passwordUpdated: true,
|
||||
mustChangePassword: true,
|
||||
tokenVersionIncremented: true,
|
||||
locksCleared: true,
|
||||
revokedSessions: 2,
|
||||
invalidatedPasswordResetCodes: 1,
|
||||
consumedEmailLoginCodes: 3,
|
||||
auditCreated: true,
|
||||
});
|
||||
expect(tx.employee.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "00000000-0000-4000-8000-000000000001" },
|
||||
data: expect.objectContaining({
|
||||
must_change_password: true,
|
||||
login_failed_count: 0,
|
||||
email_login_code_failed_count: 0,
|
||||
password_reset_failed_count: 0,
|
||||
token_version: { increment: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(tx.authSession.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.passwordResetCode.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.emailLoginCode.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET",
|
||||
extra: { credentialRecorded: false },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies the post-reset state without returning an account identifier", async () => {
|
||||
const { prisma } = createPrismaFixture();
|
||||
|
||||
await expect(verifyState(prisma)).resolves.toEqual({
|
||||
status: "PASS",
|
||||
activeAdminMatches: 1,
|
||||
exactTarget: true,
|
||||
mustChangePassword: true,
|
||||
locksCleared: true,
|
||||
activeSessions: 0,
|
||||
activePasswordResetCodes: 0,
|
||||
activeEmailLoginCodes: 0,
|
||||
auditPresent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies that the administrator completed the forced password change", async () => {
|
||||
const { prisma } = createPrismaFixture();
|
||||
prisma.employee.findUnique.mockResolvedValue({
|
||||
must_change_password: false,
|
||||
login_failed_count: 0,
|
||||
login_locked_until: null,
|
||||
email_login_code_failed_count: 0,
|
||||
email_login_code_locked_until: null,
|
||||
password_reset_failed_count: 0,
|
||||
password_reset_locked_until: null,
|
||||
});
|
||||
prisma.authSession.count.mockResolvedValue(1);
|
||||
prisma.auditLog.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
|
||||
|
||||
await expect(verifyCompletedPasswordChange(prisma)).resolves.toEqual({
|
||||
status: "PASS",
|
||||
activeAdminMatches: 1,
|
||||
exactTarget: true,
|
||||
mustChangePassword: false,
|
||||
locksCleared: true,
|
||||
activeSessions: 1,
|
||||
passwordChangeAuditPresent: true,
|
||||
breakGlassAuditPresent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies whether a recent password login happened inside the WeCom webview", async () => {
|
||||
const { prisma } = createPrismaFixture();
|
||||
prisma.auditLog.findMany = vi.fn().mockResolvedValue([
|
||||
{ action: "AUTH_LOGIN_SUCCESS", user_agent: "wxwork MicroMessenger" },
|
||||
{ action: "AUTH_LOGIN_SUCCESS", user_agent: "Desktop Browser" },
|
||||
]);
|
||||
|
||||
await expect(diagnoseWecomBinding(prisma)).resolves.toEqual({
|
||||
status: "PASS",
|
||||
activeAdminMatches: 1,
|
||||
exactTarget: true,
|
||||
targetBound: false,
|
||||
directoryUserIdPresent: false,
|
||||
latestDirectorySyncSuccessful: false,
|
||||
directoryUserIdAlreadyBoundElsewhere: false,
|
||||
linkedSuccessfulHrSyncPresent: false,
|
||||
recentPasswordLogins: 2,
|
||||
recentPasswordLoginsFromWecomWebView: 1,
|
||||
recentFailedPasswordLogins: 0,
|
||||
recentFailedPasswordLoginsFromWecomWebView: 0,
|
||||
recentWecomBindAudits: 0,
|
||||
recentWecomLoginAudits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the selected administrator changes before the transaction", async () => {
|
||||
const { prisma, tx } = createPrismaFixture();
|
||||
tx.employee.findUnique.mockResolvedValue({
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 8,
|
||||
roles: [{ role_code: "system_admin" }],
|
||||
});
|
||||
|
||||
await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow(
|
||||
"active_admin_target_changed",
|
||||
);
|
||||
expect(tx.employee.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
const {
|
||||
WecomLoginAutoBindingError,
|
||||
autoBindExistingWecomEmployee,
|
||||
fetchWecomLoginIdentity,
|
||||
} = require("../../src/services/integrations/wecomLoginAutoBinding");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
id: "request-1",
|
||||
ip: "127.0.0.1",
|
||||
headers: { "user-agent": "test-agent" },
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({
|
||||
employees,
|
||||
useridBinding = null,
|
||||
employeeBinding = null,
|
||||
} = {}) {
|
||||
const tx = {
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue(
|
||||
employees ?? [
|
||||
{
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E0001",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 3,
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(useridBinding)
|
||||
.mockResolvedValueOnce(employeeBinding),
|
||||
create: vi.fn().mockResolvedValue({ id: "binding-1" }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) },
|
||||
};
|
||||
return {
|
||||
tx,
|
||||
prisma: { $transaction: vi.fn(async (callback) => callback(tx)) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("WeCom passwordless login auto binding", () => {
|
||||
it("reads and normalizes the enterprise email for the OAuth userid", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errcode: 0,
|
||||
userid: "wecom-user-1",
|
||||
status: 1,
|
||||
biz_mail: "[email protected]",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchWecomLoginIdentity({
|
||||
accessToken: "access-token",
|
||||
externalUserId: "wecom-user-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
externalUserId: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
});
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toContain("/cgi-bin/user/get");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"inactive member",
|
||||
{
|
||||
errcode: 0,
|
||||
userid: "wecom-user-1",
|
||||
status: 2,
|
||||
biz_mail: "[email protected]",
|
||||
},
|
||||
"wecom_member_inactive",
|
||||
],
|
||||
[
|
||||
"identity mismatch",
|
||||
{
|
||||
errcode: 0,
|
||||
userid: "another-user",
|
||||
status: 1,
|
||||
biz_mail: "[email protected]",
|
||||
},
|
||||
"wecom_identity_mismatch",
|
||||
],
|
||||
])("fails closed for %s", async (_label, payload, code) => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: async () => payload });
|
||||
|
||||
await expect(
|
||||
fetchWecomLoginIdentity({
|
||||
accessToken: "access-token",
|
||||
externalUserId: "wecom-user-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toMatchObject({ code });
|
||||
});
|
||||
|
||||
it("keeps userid matching available when the directory profile has no enterprise email", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, userid: "wecom-user-1", status: 1 }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchWecomLoginIdentity({
|
||||
accessToken: "access-token",
|
||||
externalUserId: "wecom-user-1",
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toEqual({ externalUserId: "wecom-user-1", email: null });
|
||||
});
|
||||
|
||||
it("binds a unique active OA employee by enterprise email and writes a redacted audit", async () => {
|
||||
const { prisma, tx } = buildPrisma();
|
||||
|
||||
const result = await autoBindExistingWecomEmployee({
|
||||
prisma,
|
||||
request: buildRequest(),
|
||||
identity: {
|
||||
externalUserId: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
},
|
||||
externalOpenUserId: "open-user-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: EMPLOYEE_ID,
|
||||
status: "active",
|
||||
token_version: 3,
|
||||
autoBound: true,
|
||||
});
|
||||
expect(tx.employee.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
OR: [
|
||||
{ employee_no: "wecom-user-1" },
|
||||
{
|
||||
email: {
|
||||
equals: "[email protected]",
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tx.wecomIdentityBinding.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
external_user_id: "wecom-user-1",
|
||||
external_open_user_id: "open-user-1",
|
||||
}),
|
||||
});
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_AUTO_BOUND",
|
||||
extra: { matchBy: "email", emailMatched: true },
|
||||
}),
|
||||
});
|
||||
expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain(
|
||||
"[email protected]",
|
||||
);
|
||||
expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain(
|
||||
"wecom-user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when userid and email point to different OA employees", async () => {
|
||||
const { prisma, tx } = buildPrisma({
|
||||
employees: [
|
||||
{
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 0,
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
employee_no: "E0002",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
autoBindExistingWecomEmployee({
|
||||
prisma,
|
||||
request: buildRequest(),
|
||||
identity: {
|
||||
externalUserId: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "oa_employee_conflict" });
|
||||
expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds by exact userid when the OA employee number already carries the userid", async () => {
|
||||
const { prisma, tx } = buildPrisma({
|
||||
employees: [
|
||||
{
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
token_version: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
autoBindExistingWecomEmployee({
|
||||
prisma,
|
||||
request: buildRequest(),
|
||||
identity: { externalUserId: "wecom-user-1", email: null },
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({ id: EMPLOYEE_ID, autoBound: true }),
|
||||
);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
extra: { matchBy: "userid", emailMatched: false },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite an OA employee binding for another userid", async () => {
|
||||
const { prisma, tx } = buildPrisma({
|
||||
employeeBinding: { external_user_id: "another-wecom-user" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
autoBindExistingWecomEmployee({
|
||||
prisma,
|
||||
request: buildRequest(),
|
||||
identity: {
|
||||
externalUserId: "wecom-user-1",
|
||||
email: "[email protected]",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "oa_employee_binding_conflict" });
|
||||
expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes only stable error codes", () => {
|
||||
const error = new WecomLoginAutoBindingError(
|
||||
"oa_employee_not_found",
|
||||
"internal detail",
|
||||
);
|
||||
expect(error.code).toBe("oa_employee_not_found");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户