R85 企微未绑定登录后自动补绑定
这个提交包含在:
@@ -11,6 +11,9 @@ const { createNotifications } = require("../services/notifications");
|
|||||||
const { sendNotificationEmailSafe } = require("../services/email");
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const { generators, getOidcClient } = require("../services/oidc");
|
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) {
|
function getHeaderString(request, name) {
|
||||||
const value = request.headers[name];
|
const value = request.headers[name];
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
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) {
|
function sessionHintCookieOptions(config) {
|
||||||
return {
|
return {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
@@ -272,6 +285,8 @@ async function authRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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) {
|
if (employee.login_locked_until && employee.login_locked_until > now) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "AUTH_002",
|
code: "AUTH_002",
|
||||||
@@ -371,6 +386,7 @@ async function authRoutes(fastify) {
|
|||||||
const refreshToken = randomRefreshToken();
|
const refreshToken = randomRefreshToken();
|
||||||
const refreshTokenHash = sha256Hex(refreshToken);
|
const refreshTokenHash = sha256Hex(refreshToken);
|
||||||
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
||||||
|
let wecomBindingStatus = null;
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
await tx.employee.update({
|
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({
|
await tx.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: employee.id,
|
actorId: employee.id,
|
||||||
@@ -431,12 +504,16 @@ async function authRoutes(fastify) {
|
|||||||
refreshCookieOptions(fastify.config),
|
refreshCookieOptions(fastify.config),
|
||||||
);
|
);
|
||||||
reply.setCookie("has_session", "1", sessionHintCookieOptions(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);
|
const accessToken = fastify.issueAccessToken(employee);
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
accessToken,
|
accessToken,
|
||||||
expiresIn: accessTokenExpiresIn,
|
expiresIn: accessTokenExpiresIn,
|
||||||
mustChangePassword: Boolean(employee.must_change_password),
|
mustChangePassword: Boolean(employee.must_change_password),
|
||||||
|
wecomBindingStatus,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
|
|||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
|
|
||||||
const BIND_COOKIE_NAME = "wecom_bind";
|
const BIND_COOKIE_NAME = "wecom_bind";
|
||||||
|
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||||
const DEFAULT_RETURN_TO = "/portal";
|
const DEFAULT_RETURN_TO = "/portal";
|
||||||
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
|
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) {
|
function resolvePrimaryAppOrigin(config, request) {
|
||||||
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
|
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
|
||||||
const first = configured
|
const first = configured
|
||||||
@@ -481,6 +492,17 @@ async function wecomAuthRoutes(fastify) {
|
|||||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||||
});
|
});
|
||||||
if (!binding?.employee || binding.employee.status !== "active") {
|
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`);
|
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
const fastifyCookie = require("@fastify/cookie");
|
||||||
|
|
||||||
|
const authRoutes = require("../../../src/routes/auth");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const { hashPassword } = require("../../../src/utils/crypto");
|
||||||
|
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||||
|
|
||||||
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const JWT_SECRET = "test-auth-secret";
|
||||||
|
|
||||||
|
async function buildApp(passwordHash) {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
|
||||||
|
const employeeFindFirst = vi.fn().mockResolvedValue({
|
||||||
|
id: EMPLOYEE_ID,
|
||||||
|
status: "active",
|
||||||
|
password_hash: passwordHash,
|
||||||
|
token_version: 3,
|
||||||
|
must_change_password: false,
|
||||||
|
login_failed_count: 0,
|
||||||
|
login_failed_first_at: null,
|
||||||
|
login_locked_until: null,
|
||||||
|
});
|
||||||
|
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||||
|
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||||
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||||
|
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
employee: {
|
||||||
|
update: employeeUpdate,
|
||||||
|
},
|
||||||
|
authSession: {
|
||||||
|
findMany: authSessionFindMany,
|
||||||
|
updateMany: authSessionUpdateMany,
|
||||||
|
create: authSessionCreate,
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: auditCreate,
|
||||||
|
},
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findFirst: wecomFindFirst,
|
||||||
|
upsert: wecomUpsert,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
app.decorate("config", {
|
||||||
|
accessTokenTtl: "15m",
|
||||||
|
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
||||||
|
refreshCookieName: "refresh_token",
|
||||||
|
cookieSecure: false,
|
||||||
|
loginFailWindowSeconds: 300,
|
||||||
|
loginLockSeconds: 900,
|
||||||
|
loginFailMaxAttempts: 5,
|
||||||
|
rateLimitEnabled: false,
|
||||||
|
jwtSecret: JWT_SECRET,
|
||||||
|
appOrigin: "http://127.0.0.1:3000",
|
||||||
|
wecomAllowedReturnHosts: "127.0.0.1:3000",
|
||||||
|
});
|
||||||
|
app.decorate("prisma", {
|
||||||
|
employee: {
|
||||||
|
findFirst: employeeFindFirst,
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
|
});
|
||||||
|
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||||
|
app.decorate("authenticate", async () => undefined);
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.status(500).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
employeeFindFirst,
|
||||||
|
employeeUpdate,
|
||||||
|
authSessionFindMany,
|
||||||
|
authSessionUpdateMany,
|
||||||
|
authSessionCreate,
|
||||||
|
auditCreate,
|
||||||
|
wecomFindFirst,
|
||||||
|
wecomUpsert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("auth login route", () => {
|
||||||
|
it("auto-binds pending wecom identity after password login", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||||
|
const pendingBindToken = signPayload({
|
||||||
|
exp: Date.now() + 60_000,
|
||||||
|
externalUserId: "wecom-user-1",
|
||||||
|
externalOpenUserId: "openid-1",
|
||||||
|
returnTo: "/portal",
|
||||||
|
}, JWT_SECRET);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-device-fingerprint": "pw-e2e-admin",
|
||||||
|
cookie: `wecom_login_bind=${pendingBindToken}`,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
identifier: "[email protected]",
|
||||||
|
password: "AdminPassword1234",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
accessToken: "access-token",
|
||||||
|
mustChangePassword: false,
|
||||||
|
wecomBindingStatus: "bound",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(response.headers["set-cookie"]).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("wecom_login_bind="),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -185,6 +185,7 @@ describe("wecom auth routes R27", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(302);
|
expect(response.statusCode).toBe(302);
|
||||||
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
||||||
|
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind=");
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -215,6 +216,7 @@ describe("wecom auth routes R27", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(302);
|
expect(response.statusCode).toBe(302);
|
||||||
expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound");
|
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(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
|
expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({
|
||||||
where: { external_user_id: "wecom-unbound-lowercase" },
|
where: { external_user_id: "wecom-unbound-lowercase" },
|
||||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户